@interactive-inc/claude-funnel 0.8.0 → 0.10.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/README.md +179 -80
- package/dist/bin.js +693 -698
- package/dist/connector-adapter-CXB-q_XC.d.ts +11 -0
- package/dist/connector-adapter-D5Utumgz.js +4 -0
- package/dist/connectors/discord.d.ts +76 -0
- package/dist/connectors/discord.js +2 -0
- package/dist/connectors/gh.d.ts +38 -0
- package/dist/connectors/gh.js +2 -0
- package/dist/connectors/schedule.d.ts +53 -0
- package/dist/connectors/schedule.js +2 -0
- package/dist/connectors/slack.d.ts +62 -0
- package/dist/connectors/slack.js +2 -0
- package/dist/discord-connector-schema-Dww2I4zH.d.ts +14 -0
- package/dist/discord-connector-schema-ygf5Df-2.js +173 -0
- package/dist/file-system-Co60LrmR.d.ts +74 -0
- package/dist/gateway/daemon.js +243 -221
- package/dist/gh-connector-schema-2ml29MBC.js +218 -0
- package/dist/gh-connector-schema-BZFAS-p-.d.ts +45 -0
- package/dist/index.d.ts +3888 -0
- package/dist/index.js +6296 -0
- package/dist/logger-CTlXs7z4.d.ts +33 -0
- package/dist/node-logger-DQz_BGOD.js +61 -0
- package/dist/schedule-connector-schema-CkuIQ0JQ.js +325 -0
- package/dist/slack-connector-schema-Cd22WiHB.js +153 -0
- package/dist/slack-connector-schema-D7zAHN8k.d.ts +15 -0
- package/lib/bin.ts +1 -76
- package/lib/cli/index.ts +85 -0
- package/lib/cli/router/to-request.ts +1 -0
- package/lib/cli/routes/channels.$channel.publish.ts +52 -0
- package/lib/cli/routes/claude.ts +1 -0
- package/lib/cli/routes/index.ts +35 -18
- package/lib/cli/routes/profiles.add.$profile.ts +5 -2
- package/lib/cli/routes/profiles.set.$profile.ts +10 -11
- package/lib/connectors/discord.ts +4 -0
- package/lib/connectors/gh.ts +3 -0
- package/lib/connectors/schedule.ts +4 -0
- package/lib/connectors/slack.ts +4 -0
- package/lib/engine/claude/claude.ts +6 -0
- package/lib/engine/mcp/channel-server.ts +34 -115
- package/lib/engine/mcp/channel-subscriber.ts +82 -0
- package/lib/engine/mcp/read-channel-connectors.ts +34 -0
- package/lib/engine/mcp/read-gateway-token.ts +16 -0
- package/lib/engine/mcp/usage-hint-for-type.ts +15 -0
- package/lib/engine/settings/settings-schema.ts +2 -0
- package/lib/funnel.ts +162 -55
- package/lib/gateway/broadcaster.ts +1 -1
- package/lib/gateway/channel-publisher.ts +67 -0
- package/lib/gateway/gateway-server.ts +28 -16
- package/lib/gateway/publish-schema.ts +27 -0
- package/lib/gateway/routes/channels.publish.ts +44 -0
- package/lib/gateway/routes/index.ts +2 -0
- package/lib/gateway/routes/route-deps.ts +8 -0
- package/lib/index.ts +17 -0
- package/package.json +41 -25
package/lib/cli/index.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import pkg from "@/../package.json" with { type: "json" }
|
|
2
|
+
import { startChannelServer } from "@/engine/mcp/channel-server"
|
|
3
|
+
import { toRequest } from "@/cli/router/to-request"
|
|
4
|
+
import { launchTui } from "@/tui/tui"
|
|
5
|
+
import { createCliApp } from "@/cli/routes"
|
|
6
|
+
import { Funnel } from "@/funnel"
|
|
7
|
+
|
|
8
|
+
process.title = "funnel"
|
|
9
|
+
|
|
10
|
+
const funnel = new Funnel()
|
|
11
|
+
|
|
12
|
+
const app = createCliApp(funnel)
|
|
13
|
+
|
|
14
|
+
const HELP = `funnel — Open Claude Funnel
|
|
15
|
+
|
|
16
|
+
usage: funnel [command]
|
|
17
|
+
|
|
18
|
+
commands:
|
|
19
|
+
(none) launch TUI
|
|
20
|
+
claude launch Claude Code (default profile or --profile)
|
|
21
|
+
channels manage subscription boxes (and their nested connectors)
|
|
22
|
+
profiles manage launch profiles
|
|
23
|
+
gateway manage the gateway daemon (HTTP + WS)
|
|
24
|
+
status show overall connection status
|
|
25
|
+
update update funnel to the latest version
|
|
26
|
+
mcp run as an MCP server (invoked from .mcp.json)
|
|
27
|
+
|
|
28
|
+
options:
|
|
29
|
+
--help, -h show help
|
|
30
|
+
--version, -v show version
|
|
31
|
+
|
|
32
|
+
more: funnel <command> --help`
|
|
33
|
+
|
|
34
|
+
const args = process.argv.slice(2)
|
|
35
|
+
|
|
36
|
+
if (args.length === 0) {
|
|
37
|
+
await launchTui(funnel)
|
|
38
|
+
process.exit(0)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (args[0] === "--version" || args[0] === "-v") {
|
|
42
|
+
process.stdout.write(`${pkg.version}\n`)
|
|
43
|
+
process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (args[0] === "mcp") {
|
|
47
|
+
await startChannelServer({ dir: funnel.paths.dir })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (args[0] !== "mcp") {
|
|
51
|
+
const { method, url } = toRequest(args)
|
|
52
|
+
|
|
53
|
+
const parsed = new URL(url)
|
|
54
|
+
|
|
55
|
+
const wantsHelp = parsed.searchParams.has("help")
|
|
56
|
+
|
|
57
|
+
if (wantsHelp && parsed.pathname === "/") {
|
|
58
|
+
process.stdout.write(`${HELP}\n`)
|
|
59
|
+
process.exit(0)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const res = await app.request(url, { method })
|
|
63
|
+
|
|
64
|
+
if (res.ok) {
|
|
65
|
+
const body = await res.text()
|
|
66
|
+
if (body) process.stdout.write(`${body}\n`)
|
|
67
|
+
process.exit(0)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (wantsHelp) {
|
|
71
|
+
const segments = parsed.pathname.split("/").filter(Boolean)
|
|
72
|
+
const group = segments[0]
|
|
73
|
+
const fallback = group
|
|
74
|
+
? await app.request(`http://localhost/${group}?help=true`, { method: "GET" })
|
|
75
|
+
: null
|
|
76
|
+
|
|
77
|
+
const text = fallback?.ok ? await fallback.text() : HELP
|
|
78
|
+
process.stdout.write(`${text}\n`)
|
|
79
|
+
process.exit(0)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const text = await res.text()
|
|
83
|
+
if (text) process.stderr.write(`${text}\n`)
|
|
84
|
+
process.exit(1)
|
|
85
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { HTTPException } from "hono/http-exception"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { factory } from "@/cli/factory"
|
|
4
|
+
import { zValidator } from "@/cli/router/validator"
|
|
5
|
+
|
|
6
|
+
export const publishHelp = `funnel channels <channel> publish — push arbitrary content into a channel
|
|
7
|
+
|
|
8
|
+
usage: funnel channels <channel> publish --content="<text>" [--connector=<name>] [--meta-<key>=<value> ...]
|
|
9
|
+
|
|
10
|
+
options:
|
|
11
|
+
--content Required. The event body delivered to subscribers.
|
|
12
|
+
--connector Optional. Stamp the event with a connector name (resolved to id when found).
|
|
13
|
+
--meta-<key> Optional. Repeatable. Added to meta. Example: --meta-source=cron`
|
|
14
|
+
|
|
15
|
+
const querySchema = z
|
|
16
|
+
.object({
|
|
17
|
+
content: z.string().min(1, { message: "--content is required" }),
|
|
18
|
+
connector: z.string().min(1).optional(),
|
|
19
|
+
})
|
|
20
|
+
.passthrough()
|
|
21
|
+
|
|
22
|
+
export const channelsPublishHandler = factory.createHandlers(
|
|
23
|
+
zValidator("param", z.object({ channel: z.string() })),
|
|
24
|
+
zValidator("query", querySchema, publishHelp),
|
|
25
|
+
async (c) => {
|
|
26
|
+
const param = c.req.valid("param")
|
|
27
|
+
const query = c.req.valid("query")
|
|
28
|
+
const funnel = c.var.funnel
|
|
29
|
+
|
|
30
|
+
const meta: Record<string, string> = {}
|
|
31
|
+
|
|
32
|
+
for (const [k, v] of new URL(c.req.url).searchParams) {
|
|
33
|
+
if (k.startsWith("meta-")) meta[k.slice("meta-".length)] = v
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const result = await funnel.publisher.publish(param.channel, {
|
|
37
|
+
content: query.content,
|
|
38
|
+
connector: query.connector,
|
|
39
|
+
meta: Object.keys(meta).length > 0 ? meta : undefined,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (result.state === "offline") {
|
|
43
|
+
throw new HTTPException(503, { message: "gateway daemon is not running — start it with `fnl gateway start`" })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (result.state === "error") {
|
|
47
|
+
throw new HTTPException(502, { message: result.reason })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return c.text(`published (offset=${result.offset})`)
|
|
51
|
+
},
|
|
52
|
+
)
|
package/lib/cli/routes/claude.ts
CHANGED
package/lib/cli/routes/index.ts
CHANGED
|
@@ -32,6 +32,10 @@ import {
|
|
|
32
32
|
channelsConnectorsSchedulesRemoveHandler,
|
|
33
33
|
removeHelp as channelsConnectorsSchedulesRemoveHelp,
|
|
34
34
|
} from "@/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id"
|
|
35
|
+
import {
|
|
36
|
+
channelsPublishHandler,
|
|
37
|
+
publishHelp as channelsPublishHelp,
|
|
38
|
+
} from "@/cli/routes/channels.$channel.publish"
|
|
35
39
|
import {
|
|
36
40
|
channelsRemoveHandler,
|
|
37
41
|
removeHelp as channelsRemoveHelp,
|
|
@@ -75,30 +79,37 @@ import { statusHandler } from "@/cli/routes/status"
|
|
|
75
79
|
import { updateHandler } from "@/cli/routes/update"
|
|
76
80
|
import { Funnel } from "@/funnel"
|
|
77
81
|
|
|
78
|
-
const
|
|
82
|
+
const helpRoute = (text: string) => factory.createHandlers((c) => c.text(text))
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Build the CLI Hono app wired to a specific Funnel instance.
|
|
86
|
+
* Exposed so library consumers can mount the same routes their `fnl` CLI
|
|
87
|
+
* uses against a custom Funnel (e.g. one with sandboxed boundaries).
|
|
88
|
+
*
|
|
89
|
+
* All CLI verbs (`add` / `remove` / `set` / `rename` / `as-default` / `request`) map to POST in
|
|
90
|
+
* to-request.ts and stay in the URL as a literal segment. Read paths (list / show / launch) keep GET.
|
|
91
|
+
* Help shortcuts at parameterless URLs return the help text directly so `funnel <verb>` (no args) is
|
|
92
|
+
* informative instead of 404.
|
|
93
|
+
*/
|
|
94
|
+
export const createCliApp = (funnel: Funnel) => {
|
|
95
|
+
const base = factory.createApp()
|
|
82
96
|
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
base.use((c, next) => {
|
|
98
|
+
c.set("funnel", funnel)
|
|
85
99
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return c.text(`error: ${error.message}`, error.status)
|
|
89
|
-
}
|
|
100
|
+
return next()
|
|
101
|
+
})
|
|
90
102
|
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
base.onError((error, c) => {
|
|
104
|
+
if (error instanceof HTTPException) {
|
|
105
|
+
return c.text(`error: ${error.message}`, error.status)
|
|
106
|
+
}
|
|
93
107
|
|
|
94
|
-
|
|
108
|
+
return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
109
|
+
})
|
|
95
110
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// Help shortcuts at parameterless URLs return the help text directly so `funnel <verb>` (no args) is
|
|
99
|
-
// informative instead of 404.
|
|
100
|
-
export const app = base
|
|
101
|
-
.get("/claude", ...claudeHandler)
|
|
111
|
+
return base
|
|
112
|
+
.get("/claude", ...claudeHandler)
|
|
102
113
|
.get("/channels", ...channelsGroupHandler)
|
|
103
114
|
.post("/channels/add", ...helpRoute(channelsAddHelp))
|
|
104
115
|
.post("/channels/add/:channel", ...channelsAddHandler)
|
|
@@ -109,6 +120,8 @@ export const app = base
|
|
|
109
120
|
.post("/channels/rename", ...helpRoute(channelsRenameHelp))
|
|
110
121
|
.post("/channels/:channel/rename", ...helpRoute(channelsRenameHelp))
|
|
111
122
|
.post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler)
|
|
123
|
+
.post("/channels/publish", ...helpRoute(channelsPublishHelp))
|
|
124
|
+
.post("/channels/:channel/publish", ...channelsPublishHandler)
|
|
112
125
|
.get("/channels/:channel", ...channelsShowHandler)
|
|
113
126
|
.get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler)
|
|
114
127
|
.post(
|
|
@@ -200,3 +213,7 @@ export const app = base
|
|
|
200
213
|
.get("/gateway/listeners", ...gatewayListenersHandler)
|
|
201
214
|
.get("/status", ...statusHandler)
|
|
202
215
|
.get("/update", ...updateHandler)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
|
|
219
|
+
export const app = createCliApp(new Funnel())
|
|
@@ -5,12 +5,13 @@ import { zValidator } from "@/cli/router/validator"
|
|
|
5
5
|
|
|
6
6
|
export const addHelp = `funnel profiles add — add a profile
|
|
7
7
|
|
|
8
|
-
usage: funnel profiles add <name> --path <path> --sub-agent <agent> --channel <channel-name>
|
|
8
|
+
usage: funnel profiles add <name> --path <path> --sub-agent <agent> --channel <channel-name> [--brief]
|
|
9
9
|
|
|
10
10
|
options:
|
|
11
11
|
--path working directory passed to claude as cwd
|
|
12
12
|
--sub-agent sub-agent name passed to claude --agent
|
|
13
|
-
--channel channel name (resolved to channel id internally)
|
|
13
|
+
--channel channel name (resolved to channel id internally)
|
|
14
|
+
--brief forward --brief to claude on launch (enables SendUserMessage tool)`
|
|
14
15
|
|
|
15
16
|
export const profilesAddHandler = factory.createHandlers(
|
|
16
17
|
zValidator("param", z.object({ profile: z.string() })),
|
|
@@ -20,6 +21,7 @@ export const profilesAddHandler = factory.createHandlers(
|
|
|
20
21
|
path: z.string(),
|
|
21
22
|
"sub-agent": z.string(),
|
|
22
23
|
channel: z.string(),
|
|
24
|
+
brief: z.coerce.boolean().optional(),
|
|
23
25
|
}),
|
|
24
26
|
addHelp,
|
|
25
27
|
),
|
|
@@ -39,6 +41,7 @@ export const profilesAddHandler = factory.createHandlers(
|
|
|
39
41
|
path: query.path,
|
|
40
42
|
subAgent: query["sub-agent"],
|
|
41
43
|
channelId: channel.id,
|
|
44
|
+
...(query.brief !== undefined ? { brief: query.brief } : {}),
|
|
42
45
|
})
|
|
43
46
|
|
|
44
47
|
return c.text(`added profile "${param.profile}"`)
|
|
@@ -5,7 +5,7 @@ import { zValidator } from "@/cli/router/validator"
|
|
|
5
5
|
|
|
6
6
|
export const setHelp = `funnel profiles <name> set — update a profile
|
|
7
7
|
|
|
8
|
-
usage: funnel profiles <name> set [--path <path>] [--sub-agent <agent>] [--channel <channel-name>]`
|
|
8
|
+
usage: funnel profiles <name> set [--path <path>] [--sub-agent <agent>] [--channel <channel-name>] [--brief | --no-brief]`
|
|
9
9
|
|
|
10
10
|
export const profilesSetHandler = factory.createHandlers(
|
|
11
11
|
zValidator("param", z.object({ profile: z.string() })),
|
|
@@ -15,6 +15,8 @@ export const profilesSetHandler = factory.createHandlers(
|
|
|
15
15
|
path: z.string().optional(),
|
|
16
16
|
"sub-agent": z.string().optional(),
|
|
17
17
|
channel: z.string().optional(),
|
|
18
|
+
brief: z.coerce.boolean().optional(),
|
|
19
|
+
"no-brief": z.coerce.boolean().optional(),
|
|
18
20
|
}),
|
|
19
21
|
setHelp,
|
|
20
22
|
),
|
|
@@ -23,22 +25,19 @@ export const profilesSetHandler = factory.createHandlers(
|
|
|
23
25
|
const query = c.req.valid("query")
|
|
24
26
|
const funnel = c.var.funnel
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
const channel = query.channel !== undefined ? funnel.channels.get(query.channel) : null
|
|
27
29
|
|
|
28
|
-
if (query.channel !== undefined) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (!channel) {
|
|
32
|
-
throw new HTTPException(400, { message: `channel "${query.channel}" not found` })
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
channelId = channel.id
|
|
30
|
+
if (query.channel !== undefined && !channel) {
|
|
31
|
+
throw new HTTPException(400, { message: `channel "${query.channel}" not found` })
|
|
36
32
|
}
|
|
37
33
|
|
|
34
|
+
const brief = query["no-brief"] ? false : query.brief
|
|
35
|
+
|
|
38
36
|
funnel.profiles.update(param.profile, {
|
|
39
37
|
path: query.path,
|
|
40
38
|
subAgent: query["sub-agent"],
|
|
41
|
-
channelId,
|
|
39
|
+
channelId: channel?.id,
|
|
40
|
+
...(brief !== undefined ? { brief } : {}),
|
|
42
41
|
})
|
|
43
42
|
|
|
44
43
|
return c.text(`updated profile "${param.profile}"`)
|
|
@@ -16,6 +16,8 @@ export type LaunchOptions = {
|
|
|
16
16
|
subAgent?: string
|
|
17
17
|
userArgs?: string[]
|
|
18
18
|
profileName?: string
|
|
19
|
+
/** Forward `--brief` to claude on launch (enables the SendUserMessage tool). */
|
|
20
|
+
brief?: boolean
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
type Deps = {
|
|
@@ -182,6 +184,10 @@ export class FunnelClaude {
|
|
|
182
184
|
result.push("--agent", options.subAgent)
|
|
183
185
|
}
|
|
184
186
|
|
|
187
|
+
if (options.brief && !result.includes("--brief")) {
|
|
188
|
+
result.push("--brief")
|
|
189
|
+
}
|
|
190
|
+
|
|
185
191
|
return result
|
|
186
192
|
}
|
|
187
193
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
1
|
import { homedir } from "node:os"
|
|
3
2
|
import { join } from "node:path"
|
|
4
3
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
|
@@ -7,71 +6,36 @@ import {
|
|
|
7
6
|
CallToolRequestSchema,
|
|
8
7
|
ListToolsRequestSchema,
|
|
9
8
|
} from "@modelcontextprotocol/sdk/types.js"
|
|
9
|
+
import { FunnelChannelSubscriber } from "@/engine/mcp/channel-subscriber"
|
|
10
10
|
import { FUNNEL_MCP_NAME } from "@/engine/mcp/mcp"
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (!existsSync(path)) return null
|
|
28
|
-
|
|
29
|
-
const value = readFileSync(path, "utf-8").trim()
|
|
30
|
-
|
|
31
|
-
return value.length > 0 ? value : null
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const readChannelConnectors = (
|
|
35
|
-
channelId: string,
|
|
36
|
-
): { channelName: string; connectors: { name: string; type: string }[] } | null => {
|
|
37
|
-
if (!existsSync(SETTINGS_PATH)) return null
|
|
38
|
-
|
|
39
|
-
const raw = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"))
|
|
40
|
-
const parsed = settingsSchema.safeParse(raw)
|
|
41
|
-
|
|
42
|
-
if (!parsed.success) return null
|
|
43
|
-
|
|
44
|
-
const channel = parsed.data.channels.find((c) => c.id === channelId)
|
|
45
|
-
|
|
46
|
-
if (!channel) return null
|
|
47
|
-
|
|
48
|
-
const connectors = channel.connectors
|
|
49
|
-
.filter((c) => TOOL_CONNECTOR_TYPES.has(c.type))
|
|
50
|
-
.map((c) => ({ name: c.name, type: c.type }))
|
|
51
|
-
|
|
52
|
-
return { channelName: channel.name, connectors }
|
|
11
|
+
import { readChannelConnectors } from "@/engine/mcp/read-channel-connectors"
|
|
12
|
+
import { readGatewayToken } from "@/engine/mcp/read-gateway-token"
|
|
13
|
+
import { usageHintForType } from "@/engine/mcp/usage-hint-for-type"
|
|
14
|
+
|
|
15
|
+
const DEFAULT_FUNNEL_DIR = join(homedir(), ".funnel")
|
|
16
|
+
const DEFAULT_GATEWAY_BASE_URL = "http://localhost:9742"
|
|
17
|
+
|
|
18
|
+
export type ChannelServerOptions = {
|
|
19
|
+
/** Funnel home directory (settings.json + gateway.token). Defaults to ~/.funnel. */
|
|
20
|
+
dir?: string
|
|
21
|
+
/** Gateway base URL. Defaults to `$FUNNEL_GATEWAY_URL` or `http://localhost:9742`. */
|
|
22
|
+
gatewayUrl?: string
|
|
23
|
+
/** Channel id to subscribe to. Defaults to `$FUNNEL_CHANNEL_ID`. */
|
|
24
|
+
channelId?: string
|
|
25
|
+
/** Auth token. Defaults to `$FUNNEL_GATEWAY_TOKEN` then `<dir>/gateway.token`. */
|
|
26
|
+
token?: string
|
|
53
27
|
}
|
|
54
28
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return "GitHub REST via gh CLI. method=POST path=repos/owner/repo/issues/N/comments body={body}"
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return "Generic adapter call."
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export const startChannelServer = async (): Promise<void> => {
|
|
72
|
-
const channelId = process.env.FUNNEL_CHANNEL_ID
|
|
73
|
-
const channel = channelId ? readChannelConnectors(channelId) : null
|
|
74
|
-
const token = readGatewayToken()
|
|
29
|
+
export const startChannelServer = async (
|
|
30
|
+
options: ChannelServerOptions = {},
|
|
31
|
+
): Promise<void> => {
|
|
32
|
+
const dir = options.dir ?? DEFAULT_FUNNEL_DIR
|
|
33
|
+
const gatewayBaseUrl =
|
|
34
|
+
options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? DEFAULT_GATEWAY_BASE_URL
|
|
35
|
+
const gatewayWsUrl = `${gatewayBaseUrl.replace(/^http/, "ws")}/ws`
|
|
36
|
+
const channelId = options.channelId ?? process.env.FUNNEL_CHANNEL_ID
|
|
37
|
+
const channel = channelId ? readChannelConnectors(dir, channelId) : null
|
|
38
|
+
const token = options.token ?? readGatewayToken(dir)
|
|
75
39
|
|
|
76
40
|
const server = new Server(
|
|
77
41
|
{ name: FUNNEL_MCP_NAME, version: "1.0.0" },
|
|
@@ -121,7 +85,7 @@ export const startChannelServer = async (): Promise<void> => {
|
|
|
121
85
|
throw new Error("`method` and `path` are required")
|
|
122
86
|
}
|
|
123
87
|
|
|
124
|
-
const url = `${
|
|
88
|
+
const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(connectorName)}/call`
|
|
125
89
|
const headers: Record<string, string> = { "content-type": "application/json" }
|
|
126
90
|
|
|
127
91
|
if (token) headers.authorization = `Bearer ${token}`
|
|
@@ -149,56 +113,11 @@ export const startChannelServer = async (): Promise<void> => {
|
|
|
149
113
|
|
|
150
114
|
if (!channelId) return
|
|
151
115
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const connect = () => {
|
|
158
|
-
const sinceQuery = lastOffset > 0 ? `&since=${lastOffset}` : ""
|
|
159
|
-
const wsUrl = `${baseUrl}${sinceQuery}`
|
|
160
|
-
const ws = new WebSocket(wsUrl, protocols)
|
|
161
|
-
|
|
162
|
-
ws.addEventListener("open", () => {
|
|
163
|
-
reconnectDelay = RECONNECT_DELAY
|
|
164
|
-
process.stderr.write(`funnel: connected (${wsUrl})\n`)
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
ws.addEventListener("message", async (event) => {
|
|
168
|
-
try {
|
|
169
|
-
const payload = JSON.parse(String(event.data))
|
|
170
|
-
const eventType = payload.meta?.event_type ?? "unknown"
|
|
171
|
-
|
|
172
|
-
if (typeof payload.offset === "number" && payload.offset > lastOffset) {
|
|
173
|
-
lastOffset = payload.offset
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
process.stderr.write(`funnel: received event (${eventType})\n`)
|
|
177
|
-
|
|
178
|
-
await server.notification({
|
|
179
|
-
method: "notifications/claude/channel",
|
|
180
|
-
params: {
|
|
181
|
-
content: payload.content,
|
|
182
|
-
meta: payload.meta,
|
|
183
|
-
},
|
|
184
|
-
})
|
|
185
|
-
} catch (error) {
|
|
186
|
-
process.stderr.write(
|
|
187
|
-
`funnel: error: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
188
|
-
)
|
|
189
|
-
}
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
ws.addEventListener("close", () => {
|
|
193
|
-
process.stderr.write(`funnel: disconnected, reconnecting in ${reconnectDelay}ms\n`)
|
|
194
|
-
setTimeout(connect, reconnectDelay)
|
|
195
|
-
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
ws.addEventListener("error", () => {
|
|
199
|
-
// close handler will reconnect
|
|
200
|
-
})
|
|
201
|
-
}
|
|
116
|
+
const subscriber = new FunnelChannelSubscriber({
|
|
117
|
+
server,
|
|
118
|
+
baseUrl: `${gatewayWsUrl}?channel=${encodeURIComponent(channelId)}`,
|
|
119
|
+
protocols: token ? [`funnel.token.${token}`] : undefined,
|
|
120
|
+
})
|
|
202
121
|
|
|
203
|
-
|
|
122
|
+
subscriber.start()
|
|
204
123
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
|
2
|
+
|
|
3
|
+
const RECONNECT_DELAY = 1000
|
|
4
|
+
const MAX_RECONNECT_DELAY = 10000
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
server: Server
|
|
8
|
+
baseUrl: string
|
|
9
|
+
protocols: string[] | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type State = {
|
|
13
|
+
reconnectDelay: number
|
|
14
|
+
lastOffset: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Subscribes to the gateway WebSocket for a single channel and forwards
|
|
19
|
+
* incoming events to the MCP server as `notifications/claude/channel`.
|
|
20
|
+
* Reconnects with exponential backoff and replays missed events via `?since=<offset>`.
|
|
21
|
+
*/
|
|
22
|
+
export class FunnelChannelSubscriber {
|
|
23
|
+
private readonly state: State = { reconnectDelay: RECONNECT_DELAY, lastOffset: 0 }
|
|
24
|
+
|
|
25
|
+
constructor(private readonly props: Props) {
|
|
26
|
+
Object.freeze(this)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
start(): void {
|
|
30
|
+
this.connect()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private connect(): void {
|
|
34
|
+
const sinceQuery = this.state.lastOffset > 0 ? `&since=${this.state.lastOffset}` : ""
|
|
35
|
+
const wsUrl = `${this.props.baseUrl}${sinceQuery}`
|
|
36
|
+
const ws = new WebSocket(wsUrl, this.props.protocols)
|
|
37
|
+
|
|
38
|
+
ws.addEventListener("open", () => {
|
|
39
|
+
this.state.reconnectDelay = RECONNECT_DELAY
|
|
40
|
+
process.stderr.write(`funnel: connected (${wsUrl})\n`)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
ws.addEventListener("message", (event) => this.handleMessage(event))
|
|
44
|
+
|
|
45
|
+
ws.addEventListener("close", () => {
|
|
46
|
+
process.stderr.write(
|
|
47
|
+
`funnel: disconnected, reconnecting in ${this.state.reconnectDelay}ms\n`,
|
|
48
|
+
)
|
|
49
|
+
setTimeout(() => this.connect(), this.state.reconnectDelay)
|
|
50
|
+
this.state.reconnectDelay = Math.min(this.state.reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
ws.addEventListener("error", () => {
|
|
54
|
+
// close handler will reconnect
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async handleMessage(event: MessageEvent): Promise<void> {
|
|
59
|
+
try {
|
|
60
|
+
const payload = JSON.parse(String(event.data))
|
|
61
|
+
const eventType = payload.meta?.event_type ?? "unknown"
|
|
62
|
+
|
|
63
|
+
if (typeof payload.offset === "number" && payload.offset > this.state.lastOffset) {
|
|
64
|
+
this.state.lastOffset = payload.offset
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
process.stderr.write(`funnel: received event (${eventType})\n`)
|
|
68
|
+
|
|
69
|
+
await this.props.server.notification({
|
|
70
|
+
method: "notifications/claude/channel",
|
|
71
|
+
params: {
|
|
72
|
+
content: payload.content,
|
|
73
|
+
meta: payload.meta,
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
} catch (error) {
|
|
77
|
+
process.stderr.write(
|
|
78
|
+
`funnel: error: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { settingsSchema } from "@/engine/settings/settings-schema"
|
|
4
|
+
|
|
5
|
+
const TOOL_CONNECTOR_TYPES = new Set(["slack", "gh", "discord"])
|
|
6
|
+
|
|
7
|
+
export type ChannelConnectorsView = {
|
|
8
|
+
channelName: string
|
|
9
|
+
connectors: { name: string; type: string }[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const readChannelConnectors = (
|
|
13
|
+
dir: string,
|
|
14
|
+
channelId: string,
|
|
15
|
+
): ChannelConnectorsView | null => {
|
|
16
|
+
const settingsPath = join(dir, "settings.json")
|
|
17
|
+
|
|
18
|
+
if (!existsSync(settingsPath)) return null
|
|
19
|
+
|
|
20
|
+
const raw = JSON.parse(readFileSync(settingsPath, "utf-8"))
|
|
21
|
+
const parsed = settingsSchema.safeParse(raw)
|
|
22
|
+
|
|
23
|
+
if (!parsed.success) return null
|
|
24
|
+
|
|
25
|
+
const channel = parsed.data.channels.find((c) => c.id === channelId)
|
|
26
|
+
|
|
27
|
+
if (!channel) return null
|
|
28
|
+
|
|
29
|
+
const connectors = channel.connectors
|
|
30
|
+
.filter((c) => TOOL_CONNECTOR_TYPES.has(c.type))
|
|
31
|
+
.map((c) => ({ name: c.name, type: c.type }))
|
|
32
|
+
|
|
33
|
+
return { channelName: channel.name, connectors }
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
export const readGatewayToken = (dir: string): string | null => {
|
|
5
|
+
const fromEnv = process.env.FUNNEL_GATEWAY_TOKEN
|
|
6
|
+
|
|
7
|
+
if (fromEnv && fromEnv.length > 0) return fromEnv
|
|
8
|
+
|
|
9
|
+
const path = join(dir, "gateway.token")
|
|
10
|
+
|
|
11
|
+
if (!existsSync(path)) return null
|
|
12
|
+
|
|
13
|
+
const value = readFileSync(path, "utf-8").trim()
|
|
14
|
+
|
|
15
|
+
return value.length > 0 ? value : null
|
|
16
|
+
}
|