@flomenco/claude-plugin-mcp 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.
Files changed (3) hide show
  1. package/README.md +175 -0
  2. package/package.json +45 -0
  3. package/src/index.js +982 -0
package/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # Flo Claude Plugin MCP Bridge
2
+
3
+ MCP server that connects Claude to the deployed `interface-agent` runtime for the
4
+ Flo Claude plugin happy path.
5
+
6
+ It exposes authentication + plugin tools:
7
+
8
+ - `flo_auth_login`
9
+ - `flo_auth_status`
10
+ - `flo_auth_setup_help`
11
+ - `flo_auth_logout`
12
+
13
+ - `flo_search`
14
+ - `flo_skill_routing`
15
+ - `flo_qc_logo`
16
+ - `flo_command` (raw passthrough for troubleshooting)
17
+ - `flo_plugin_healthcheck`
18
+ - `flo_happy_path_run`
19
+
20
+ ## Prerequisites
21
+
22
+ - Node `>=22.13`
23
+ - `pnpm` installed
24
+ - Deployed `interface-agent` invocation URL
25
+ - OAuth client configured for your auth provider (recommended), or static JWT fallback
26
+
27
+ ## Install dependencies
28
+
29
+ From repo root:
30
+
31
+ ```bash
32
+ pnpm install
33
+ ```
34
+
35
+ ## One-command Claude Desktop install
36
+
37
+ For repo contributors (local checkout):
38
+
39
+ ```bash
40
+ cd /Users/ryan/Dev/github/flo
41
+ # Optional: pick AWS profile used for auto-discovery
42
+ export AWS_PROFILE=469128505698_FlomencoSuperAdmin
43
+ pnpm --filter @flo/claude-plugin-mcp run install:claude-desktop
44
+ ```
45
+
46
+ Uninstall:
47
+
48
+ ```bash
49
+ pnpm --filter @flo/claude-plugin-mcp run uninstall:claude-desktop
50
+ ```
51
+
52
+ ## External-user manual install (no repo clone)
53
+
54
+ After this package is published, users can install via Claude config using `npx`:
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "flo-plugin": {
60
+ "command": "npx",
61
+ "args": ["-y", "@flo/claude-plugin-mcp"],
62
+ "env": {
63
+ "FLO_INTERFACE_AGENT_INVOCATION_URL": "https://<runtime-host>/invocations",
64
+ "FLO_OAUTH_AUTHORIZE_URL": "https://<auth-host>/oauth2/authorize",
65
+ "FLO_OAUTH_TOKEN_URL": "https://<auth-host>/oauth2/token",
66
+ "FLO_OAUTH_CLIENT_ID": "<public-client-id>",
67
+ "FLO_OAUTH_REDIRECT_URI": "http://127.0.0.1:8787/callback",
68
+ "FLO_OAUTH_SCOPES": "openid email profile"
69
+ }
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ ## Environment variables
76
+
77
+ - `FLO_INTERFACE_AGENT_INVOCATION_URL` (preferred): full invocation endpoint (`.../invocations`)
78
+ - `FLO_INTERFACE_AGENT_URL` (fallback): runtime URL without `/invocations`
79
+ - `FLO_AUTH_TOKEN` (optional fallback): static bearer token for each tool call
80
+ - `FLO_PLUGIN_ENV` (optional): `dev` (default), `stg`, or `prod` for default DNS host selection
81
+ - Default runtime URL if unset:
82
+ - `dev` -> `https://plugin.dev.floapp.co/invocations`
83
+ - `stg` -> `https://plugin.stg.floapp.co/invocations`
84
+ - `prod` -> `https://plugin.floapp.co/invocations`
85
+
86
+ OAuth (PKCE login) variables:
87
+
88
+ - `FLO_OAUTH_AUTHORIZE_URL` (required for OAuth)
89
+ - `FLO_OAUTH_TOKEN_URL` (required for OAuth)
90
+ - `FLO_OAUTH_CLIENT_ID` (required for OAuth)
91
+ - `FLO_OAUTH_REDIRECT_URI` (optional, default `http://127.0.0.1:8787/callback`)
92
+ - `FLO_OAUTH_SCOPES` (optional, default `openid email profile`)
93
+ - `FLO_OAUTH_AUDIENCE` (optional)
94
+ - `FLO_OAUTH_TOKEN_CACHE_PATH` (optional local cache path)
95
+ - `FLO_OAUTH_SETTINGS_URL` (optional explicit Flo settings URL)
96
+
97
+ ## Runtime URL and OAuth FAQ
98
+
99
+ - **Should runtime URL have a DNS short name?**
100
+ - Yes. This package uses env-specific DNS defaults (`plugin.dev`, `plugin.stg`, `plugin` for prod) and supports overriding via `FLO_INTERFACE_AGENT_INVOCATION_URL`.
101
+ - **Where do users get `FLO_OAUTH_CLIENT_ID`?**
102
+ - From Flo-distributed install config. For operators, the install script can auto-discover it from Cognito app clients (`flo-dev-claude-plugin`, fallback `flo-dev-spa`) when AWS creds are available.
103
+ - **How can operators generate a shareable config blob?**
104
+ - Run:
105
+ - `pnpm --filter @flo/claude-plugin-mcp run print:bootstrap-config`
106
+ - **Why is redirect local (`127.0.0.1`)?**
107
+ - This is the standard installed-app OAuth pattern (loopback redirect). Browser returns auth code to a short-lived local listener that exchanges it securely (PKCE).
108
+
109
+ ## Claude Desktop config
110
+
111
+ Add to `claude_desktop_config.json`:
112
+
113
+ ```json
114
+ {
115
+ "mcpServers": {
116
+ "flo-plugin": {
117
+ "command": "node",
118
+ "args": ["/ABSOLUTE/PATH/TO/flo/packages/flo-claude-plugin-mcp/src/index.js"],
119
+ "env": {
120
+ "FLO_INTERFACE_AGENT_INVOCATION_URL": "https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A469128505698%3Aruntime%2Fflo_dev_interface_agent_agent_runtime-vx0ANeHC4Z/invocations",
121
+ "FLO_OAUTH_AUTHORIZE_URL": "https://flomenco-dev.auth.us-east-1.amazoncognito.com/oauth2/authorize",
122
+ "FLO_OAUTH_TOKEN_URL": "https://flomenco-dev.auth.us-east-1.amazoncognito.com/oauth2/token",
123
+ "FLO_OAUTH_CLIENT_ID": "PASTE_SPA_CLIENT_ID_HERE",
124
+ "FLO_OAUTH_REDIRECT_URI": "http://127.0.0.1:8787/callback",
125
+ "FLO_OAUTH_SCOPES": "openid email profile"
126
+ }
127
+ }
128
+ }
129
+ }
130
+ ```
131
+
132
+ ## Claude Code config
133
+
134
+ Add to `~/.claude/settings.json` (or workspace `.claude/settings.json`):
135
+
136
+ ```json
137
+ {
138
+ "mcpServers": {
139
+ "flo-plugin": {
140
+ "command": "node",
141
+ "args": ["/ABSOLUTE/PATH/TO/flo/packages/flo-claude-plugin-mcp/src/index.js"],
142
+ "env": {
143
+ "FLO_INTERFACE_AGENT_INVOCATION_URL": "https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A469128505698%3Aruntime%2Fflo_dev_interface_agent_agent_runtime-vx0ANeHC4Z/invocations",
144
+ "FLO_OAUTH_AUTHORIZE_URL": "https://flomenco-dev.auth.us-east-1.amazoncognito.com/oauth2/authorize",
145
+ "FLO_OAUTH_TOKEN_URL": "https://flomenco-dev.auth.us-east-1.amazoncognito.com/oauth2/token",
146
+ "FLO_OAUTH_CLIENT_ID": "PASTE_SPA_CLIENT_ID_HERE",
147
+ "FLO_OAUTH_REDIRECT_URI": "http://127.0.0.1:8787/callback",
148
+ "FLO_OAUTH_SCOPES": "openid email profile"
149
+ }
150
+ }
151
+ }
152
+ }
153
+ ```
154
+
155
+ ## Quick validation
156
+
157
+ After Claude starts with MCP enabled:
158
+
159
+ - Run `flo_auth_login` once and complete browser sign-in
160
+ - Check `flo_auth_status` (expect `cachedTokenUsable: true`)
161
+ - Run `flo_auth_setup_help` if you need the Flo settings URL for locating `FLO_OAUTH_CLIENT_ID`
162
+ - Run `flo_plugin_healthcheck` (expect `runtime.reachable: true`)
163
+ - Run `flo_search` with `query="hail mary"`
164
+ - `flo_skill_routing` with an `assetId` from search
165
+ - `flo_qc_logo` with `assetId` and `referenceAssetId`
166
+
167
+ One-shot E2E:
168
+
169
+ - `flo_happy_path_run` with `referenceAssetId="<logo_asset_id>"`
170
+
171
+ Expected output shape:
172
+
173
+ - search: `status`, `menuItems[]`
174
+ - skill-routing: `status`, `skills[]`, includes `/flo:qc-logo`
175
+ - qc-logo: `status`, `result.overall|summary|assetId|assetUrl|findings`
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@flomenco/claude-plugin-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Flo Claude Co-Work plugin bridge for Claude Desktop/Code",
5
+ "private": false,
6
+ "license": "UNLICENSED",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Flomenco-Inc/flo.git",
10
+ "directory": "packages/flo-claude-plugin-mcp"
11
+ },
12
+ "homepage": "https://github.com/Flomenco-Inc/flo",
13
+ "bugs": {
14
+ "url": "https://github.com/Flomenco-Inc/flo/issues"
15
+ },
16
+ "keywords": [
17
+ "claude",
18
+ "mcp",
19
+ "plugin",
20
+ "flo"
21
+ ],
22
+ "type": "module",
23
+ "files": [
24
+ "src",
25
+ "README.md"
26
+ ],
27
+ "bin": {
28
+ "flo-claude-plugin-mcp": "./src/index.js"
29
+ },
30
+ "scripts": {
31
+ "start": "node ./src/index.js",
32
+ "install:claude-desktop": "node ./tools/install-claude-desktop.js",
33
+ "uninstall:claude-desktop": "node ./tools/uninstall-claude-desktop.js",
34
+ "print:bootstrap-config": "node ./tools/print-bootstrap-config.js"
35
+ },
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.17.4"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "engines": {
43
+ "node": ">=22.13"
44
+ }
45
+ }
package/src/index.js ADDED
@@ -0,0 +1,982 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ McpError,
7
+ ErrorCode,
8
+ } from "@modelcontextprotocol/sdk/types.js";
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ import { createServer } from "node:http";
11
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { spawn } from "node:child_process";
15
+
16
+ const DEFAULT_TIMEOUT_MS = 60_000;
17
+ const OAUTH_WAIT_TIMEOUT_MS = 180_000;
18
+ const TOKEN_REFRESH_SKEW_SECONDS = 60;
19
+ const DEFAULT_TOKEN_CACHE_PATH = path.join(
20
+ os.homedir(),
21
+ ".flo",
22
+ "claude-plugin-mcp-token.json"
23
+ );
24
+
25
+ function normalizedPluginEnv() {
26
+ const raw = (process.env.FLO_PLUGIN_ENV || "dev").trim().toLowerCase();
27
+ if (raw === "prod" || raw === "production") {
28
+ return "prod";
29
+ }
30
+ if (raw === "stg" || raw === "stage" || raw === "staging") {
31
+ return "stg";
32
+ }
33
+ return "dev";
34
+ }
35
+
36
+ function defaultInvocationUrlForEnv() {
37
+ const env = normalizedPluginEnv();
38
+ if (env === "prod") {
39
+ return "https://plugin.floapp.co/invocations";
40
+ }
41
+ if (env === "stg") {
42
+ return "https://plugin.stg.floapp.co/invocations";
43
+ }
44
+ return "https://plugin.dev.floapp.co/invocations";
45
+ }
46
+
47
+ function defaultUserPoolNameForEnv() {
48
+ const env = normalizedPluginEnv();
49
+ if (env === "prod") {
50
+ return "flo-prod";
51
+ }
52
+ if (env === "stg") {
53
+ return "flo-stg";
54
+ }
55
+ return "flo-dev";
56
+ }
57
+
58
+ function defaultExpectedClientNameForEnv() {
59
+ const env = normalizedPluginEnv();
60
+ if (env === "prod") {
61
+ return "flo-prod-claude-plugin";
62
+ }
63
+ if (env === "stg") {
64
+ return "flo-stg-claude-plugin";
65
+ }
66
+ return "flo-dev-claude-plugin";
67
+ }
68
+
69
+ function defaultSettingsUrlForEnv() {
70
+ const env = normalizedPluginEnv();
71
+ if (env === "prod") {
72
+ return "https://floapp.co/settings/api";
73
+ }
74
+ if (env === "stg") {
75
+ return "https://stg.floapp.co/settings/api";
76
+ }
77
+ return "https://dev.floapp.co/settings/api";
78
+ }
79
+
80
+ function getInvocationUrl() {
81
+ const raw =
82
+ process.env.FLO_INTERFACE_AGENT_INVOCATION_URL ||
83
+ process.env.FLO_INTERFACE_AGENT_URL ||
84
+ defaultInvocationUrlForEnv();
85
+ if (!raw) {
86
+ throw new McpError(
87
+ ErrorCode.InvalidRequest,
88
+ "Missing FLO_INTERFACE_AGENT_INVOCATION_URL (or FLO_INTERFACE_AGENT_URL)."
89
+ );
90
+ }
91
+ const trimmed = raw.trim().replace(/\/+$/, "");
92
+ return trimmed.endsWith("/invocations") ? trimmed : `${trimmed}/invocations`;
93
+ }
94
+
95
+ function getDefaultAuthToken() {
96
+ return (process.env.FLO_AUTH_TOKEN || "").trim();
97
+ }
98
+
99
+ function getOAuthConfig(strict = false) {
100
+ const authorizeUrl = (process.env.FLO_OAUTH_AUTHORIZE_URL || "").trim();
101
+ const tokenUrl = (process.env.FLO_OAUTH_TOKEN_URL || "").trim();
102
+ const clientId = (process.env.FLO_OAUTH_CLIENT_ID || "").trim();
103
+ const scope = (process.env.FLO_OAUTH_SCOPES || "openid email profile").trim();
104
+ const redirectUri = (
105
+ process.env.FLO_OAUTH_REDIRECT_URI || "http://127.0.0.1:8787/callback"
106
+ ).trim();
107
+ const audience = (process.env.FLO_OAUTH_AUDIENCE || "").trim();
108
+ const region = (process.env.AWS_REGION || "us-east-1").trim();
109
+ const userPoolId = (process.env.FLO_OAUTH_USER_POOL_ID || "").trim();
110
+ const userPoolName = (
111
+ process.env.FLO_OAUTH_USER_POOL_NAME || defaultUserPoolNameForEnv()
112
+ ).trim();
113
+ const expectedClientName = (
114
+ process.env.FLO_OAUTH_EXPECTED_CLIENT_NAME || defaultExpectedClientNameForEnv()
115
+ ).trim();
116
+ const settingsUrl = (
117
+ process.env.FLO_OAUTH_SETTINGS_URL ||
118
+ process.env.FLO_OAUTH_CLIENT_ID_HELP_URL ||
119
+ defaultSettingsUrlForEnv()
120
+ ).trim();
121
+
122
+ const ready = Boolean(authorizeUrl && tokenUrl && clientId);
123
+ if (strict && !ready) {
124
+ throw new McpError(
125
+ ErrorCode.InvalidRequest,
126
+ "OAuth not configured. Set FLO_OAUTH_AUTHORIZE_URL, FLO_OAUTH_TOKEN_URL, and FLO_OAUTH_CLIENT_ID."
127
+ );
128
+ }
129
+
130
+ return {
131
+ ready,
132
+ authorizeUrl,
133
+ tokenUrl,
134
+ clientId,
135
+ scope,
136
+ redirectUri,
137
+ audience,
138
+ region,
139
+ userPoolId,
140
+ userPoolName,
141
+ expectedClientName,
142
+ settingsUrl,
143
+ };
144
+ }
145
+
146
+ function getTokenCachePath() {
147
+ return (process.env.FLO_OAUTH_TOKEN_CACHE_PATH || DEFAULT_TOKEN_CACHE_PATH).trim();
148
+ }
149
+
150
+ function base64Url(input) {
151
+ const buff = Buffer.isBuffer(input) ? input : Buffer.from(input, "utf8");
152
+ return buff
153
+ .toString("base64")
154
+ .replace(/\+/g, "-")
155
+ .replace(/\//g, "_")
156
+ .replace(/=+$/g, "");
157
+ }
158
+
159
+ async function readCachedToken() {
160
+ try {
161
+ const raw = await readFile(getTokenCachePath(), "utf8");
162
+ const parsed = JSON.parse(raw);
163
+ if (!parsed || typeof parsed !== "object") {
164
+ return null;
165
+ }
166
+ return parsed;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ async function writeCachedToken(tokenPayload) {
173
+ const tokenPath = getTokenCachePath();
174
+ await mkdir(path.dirname(tokenPath), { recursive: true });
175
+ await writeFile(tokenPath, JSON.stringify(tokenPayload, null, 2), "utf8");
176
+ }
177
+
178
+ async function clearCachedToken() {
179
+ try {
180
+ await rm(getTokenCachePath(), { force: true });
181
+ } catch {
182
+ // Ignore cache-clear failures; callers receive success semantics.
183
+ }
184
+ }
185
+
186
+ function tokenIsUsable(tokenPayload) {
187
+ if (!tokenPayload || typeof tokenPayload.access_token !== "string") {
188
+ return false;
189
+ }
190
+ if (!tokenPayload.expires_at) {
191
+ return true;
192
+ }
193
+ const expiresAt = Number(tokenPayload.expires_at);
194
+ if (!Number.isFinite(expiresAt)) {
195
+ return false;
196
+ }
197
+ const nowSeconds = Math.floor(Date.now() / 1000);
198
+ return expiresAt > nowSeconds + TOKEN_REFRESH_SKEW_SECONDS;
199
+ }
200
+
201
+ async function exchangeToken(params) {
202
+ const oauth = getOAuthConfig(true);
203
+ const body = new URLSearchParams(params);
204
+ const response = await fetch(oauth.tokenUrl, {
205
+ method: "POST",
206
+ headers: { "content-type": "application/x-www-form-urlencoded" },
207
+ body,
208
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
209
+ });
210
+ const json = await response.json().catch(() => ({}));
211
+ if (!response.ok) {
212
+ throw new McpError(
213
+ ErrorCode.InternalError,
214
+ `OAuth token endpoint failed (${response.status}): ${JSON.stringify(json).slice(0, 500)}`
215
+ );
216
+ }
217
+ if (!json.access_token) {
218
+ throw new McpError(
219
+ ErrorCode.InternalError,
220
+ "OAuth token endpoint succeeded but no access_token was returned."
221
+ );
222
+ }
223
+ const nowSeconds = Math.floor(Date.now() / 1000);
224
+ const expiresIn = Number(json.expires_in || 3600);
225
+ return {
226
+ access_token: String(json.access_token),
227
+ refresh_token:
228
+ json.refresh_token !== undefined ? String(json.refresh_token) : undefined,
229
+ token_type: json.token_type ? String(json.token_type) : "Bearer",
230
+ scope: json.scope ? String(json.scope) : undefined,
231
+ expires_at: nowSeconds + Math.max(1, expiresIn),
232
+ };
233
+ }
234
+
235
+ async function refreshAccessTokenIfPossible(cachedToken) {
236
+ if (!cachedToken?.refresh_token) {
237
+ return null;
238
+ }
239
+ const oauth = getOAuthConfig(false);
240
+ if (!oauth.ready) {
241
+ return null;
242
+ }
243
+ try {
244
+ const refreshed = await exchangeToken({
245
+ grant_type: "refresh_token",
246
+ refresh_token: String(cachedToken.refresh_token),
247
+ client_id: oauth.clientId,
248
+ });
249
+ if (!refreshed.refresh_token) {
250
+ refreshed.refresh_token = String(cachedToken.refresh_token);
251
+ }
252
+ await writeCachedToken(refreshed);
253
+ return refreshed;
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ function openBrowser(url) {
260
+ if (process.env.FLO_OAUTH_SKIP_BROWSER === "true") {
261
+ return false;
262
+ }
263
+ try {
264
+ if (process.platform === "darwin") {
265
+ const p = spawn("open", [url], { detached: true, stdio: "ignore" });
266
+ p.unref();
267
+ return true;
268
+ }
269
+ if (process.platform === "win32") {
270
+ const p = spawn("cmd", ["/c", "start", "", url], {
271
+ detached: true,
272
+ stdio: "ignore",
273
+ });
274
+ p.unref();
275
+ return true;
276
+ }
277
+ const p = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
278
+ p.unref();
279
+ return true;
280
+ } catch {
281
+ return false;
282
+ }
283
+ }
284
+
285
+ async function waitForOAuthCode(redirectUri, expectedState) {
286
+ const parsed = new URL(redirectUri);
287
+ if (parsed.protocol !== "http:") {
288
+ throw new McpError(
289
+ ErrorCode.InvalidRequest,
290
+ `Unsupported FLO_OAUTH_REDIRECT_URI protocol: ${parsed.protocol}. Use http://127.0.0.1:<port>/callback`
291
+ );
292
+ }
293
+ const port = Number(parsed.port || "80");
294
+ const hostname = parsed.hostname;
295
+ const pathname = parsed.pathname || "/";
296
+
297
+ const server = createServer();
298
+ const code = await new Promise((resolve, reject) => {
299
+ const onRequest = (req, res) => {
300
+ try {
301
+ const reqUrl = new URL(req.url || "/", `http://${req.headers.host}`);
302
+ if (reqUrl.pathname !== pathname) {
303
+ res.statusCode = 404;
304
+ res.end("Not found");
305
+ return;
306
+ }
307
+
308
+ const oauthError = reqUrl.searchParams.get("error");
309
+ const returnedCode = reqUrl.searchParams.get("code");
310
+ const returnedState = reqUrl.searchParams.get("state");
311
+
312
+ if (oauthError) {
313
+ clearTimeout(timer);
314
+ res.statusCode = 400;
315
+ res.end("OAuth failed. You can close this window.");
316
+ reject(new Error(`OAuth provider returned error=${oauthError}`));
317
+ return;
318
+ }
319
+ if (!returnedCode) {
320
+ clearTimeout(timer);
321
+ res.statusCode = 400;
322
+ res.end("Missing code. You can close this window.");
323
+ reject(new Error("OAuth callback missing code"));
324
+ return;
325
+ }
326
+ if (returnedState !== expectedState) {
327
+ clearTimeout(timer);
328
+ res.statusCode = 400;
329
+ res.end("Invalid state. You can close this window.");
330
+ reject(new Error("OAuth state mismatch"));
331
+ return;
332
+ }
333
+
334
+ clearTimeout(timer);
335
+ res.statusCode = 200;
336
+ res.setHeader("content-type", "text/html; charset=utf-8");
337
+ res.end(
338
+ "<html><body><h3>Flo OAuth complete.</h3><p>You can close this tab and return to Claude.</p></body></html>"
339
+ );
340
+ resolve(returnedCode);
341
+ } catch (err) {
342
+ clearTimeout(timer);
343
+ reject(err);
344
+ }
345
+ };
346
+
347
+ const timeoutMs = Number(process.env.FLO_OAUTH_TIMEOUT_MS || OAUTH_WAIT_TIMEOUT_MS);
348
+ const timer = setTimeout(() => {
349
+ reject(
350
+ new Error("Timed out waiting for OAuth callback. Re-run flo_auth_login.")
351
+ );
352
+ }, timeoutMs);
353
+
354
+ server.once("error", (err) => {
355
+ clearTimeout(timer);
356
+ reject(err);
357
+ });
358
+ server.on("request", onRequest);
359
+ server.listen(port, hostname);
360
+ }).finally(async () => {
361
+ await new Promise((done) => server.close(() => done()));
362
+ });
363
+
364
+ return code;
365
+ }
366
+
367
+ async function runInteractiveOAuthLogin() {
368
+ const oauth = getOAuthConfig(true);
369
+ const verifier = base64Url(randomBytes(32));
370
+ const challenge = base64Url(
371
+ createHash("sha256").update(verifier, "utf8").digest()
372
+ );
373
+ const state = base64Url(randomBytes(24));
374
+ const authUrl = new URL(oauth.authorizeUrl);
375
+ authUrl.searchParams.set("response_type", "code");
376
+ authUrl.searchParams.set("client_id", oauth.clientId);
377
+ authUrl.searchParams.set("redirect_uri", oauth.redirectUri);
378
+ authUrl.searchParams.set("scope", oauth.scope);
379
+ authUrl.searchParams.set("state", state);
380
+ authUrl.searchParams.set("code_challenge", challenge);
381
+ authUrl.searchParams.set("code_challenge_method", "S256");
382
+ if (oauth.audience) {
383
+ authUrl.searchParams.set("audience", oauth.audience);
384
+ }
385
+
386
+ const opened = openBrowser(authUrl.toString());
387
+ if (!opened) {
388
+ throw new McpError(
389
+ ErrorCode.InternalError,
390
+ `Unable to auto-open browser. Open this URL manually and re-run flo_auth_login:\n${authUrl.toString()}`
391
+ );
392
+ }
393
+
394
+ const code = await waitForOAuthCode(oauth.redirectUri, state);
395
+ const token = await exchangeToken({
396
+ grant_type: "authorization_code",
397
+ code,
398
+ client_id: oauth.clientId,
399
+ redirect_uri: oauth.redirectUri,
400
+ code_verifier: verifier,
401
+ });
402
+ await writeCachedToken(token);
403
+ return token;
404
+ }
405
+
406
+ async function resolveAuthToken(explicitAuthToken, interactive) {
407
+ const override = explicitAuthToken?.trim();
408
+ if (override) {
409
+ return override;
410
+ }
411
+
412
+ const envToken = getDefaultAuthToken();
413
+ if (envToken) {
414
+ return envToken;
415
+ }
416
+
417
+ const oauth = getOAuthConfig(false);
418
+ if (!oauth.ready) {
419
+ return "";
420
+ }
421
+
422
+ const cachedToken = await readCachedToken();
423
+ if (tokenIsUsable(cachedToken)) {
424
+ return cachedToken.access_token;
425
+ }
426
+
427
+ const refreshedToken = await refreshAccessTokenIfPossible(cachedToken);
428
+ if (tokenIsUsable(refreshedToken)) {
429
+ return refreshedToken.access_token;
430
+ }
431
+
432
+ if (!interactive) {
433
+ return "";
434
+ }
435
+
436
+ const freshToken = await runInteractiveOAuthLogin();
437
+ return freshToken.access_token;
438
+ }
439
+
440
+ async function buildHeaders(explicitAuthToken, interactiveAuth = true) {
441
+ const headers = { "content-type": "application/json" };
442
+ const token = await resolveAuthToken(explicitAuthToken, interactiveAuth);
443
+ if (token) {
444
+ headers.Authorization = `Bearer ${token}`;
445
+ }
446
+ return headers;
447
+ }
448
+
449
+ async function invokeInterfaceAgent(prompt, explicitAuthToken, interactiveAuth = true) {
450
+ const response = await fetch(getInvocationUrl(), {
451
+ method: "POST",
452
+ headers: await buildHeaders(explicitAuthToken, interactiveAuth),
453
+ body: JSON.stringify({ prompt }),
454
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
455
+ });
456
+
457
+ const bodyText = await response.text();
458
+ if (!response.ok) {
459
+ throw new McpError(
460
+ ErrorCode.InternalError,
461
+ `interface-agent returned HTTP ${response.status}: ${bodyText.slice(0, 500)}`
462
+ );
463
+ }
464
+
465
+ return bodyText;
466
+ }
467
+
468
+ function parseMaybeJson(text) {
469
+ const trimmed = (text || "").trim();
470
+ if (!trimmed) {
471
+ return null;
472
+ }
473
+ try {
474
+ return JSON.parse(trimmed);
475
+ } catch {
476
+ return null;
477
+ }
478
+ }
479
+
480
+ function asTextResult(value) {
481
+ if (typeof value === "string") {
482
+ return { content: [{ type: "text", text: value }] };
483
+ }
484
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
485
+ }
486
+
487
+ function requireString(value, fieldName) {
488
+ if (typeof value !== "string" || !value.trim()) {
489
+ throw new McpError(
490
+ ErrorCode.InvalidParams,
491
+ `\`${fieldName}\` must be a non-empty string.`
492
+ );
493
+ }
494
+ return value.trim();
495
+ }
496
+
497
+ function assertSearchPayload(payload) {
498
+ if (!payload || payload.status !== "ok" || !Array.isArray(payload.menuItems)) {
499
+ throw new McpError(
500
+ ErrorCode.InternalError,
501
+ "Search payload did not match expected contract."
502
+ );
503
+ }
504
+ const first = payload.menuItems[0];
505
+ if (!first || typeof first.assetId !== "string" || !first.assetId) {
506
+ throw new McpError(
507
+ ErrorCode.InternalError,
508
+ "Search payload missing first menu item assetId."
509
+ );
510
+ }
511
+ return first.assetId;
512
+ }
513
+
514
+ function assertSkillRoutingPayload(payload) {
515
+ if (!payload || payload.status !== "ok" || !Array.isArray(payload.skills)) {
516
+ throw new McpError(
517
+ ErrorCode.InternalError,
518
+ "Skill routing payload did not match expected contract."
519
+ );
520
+ }
521
+ const hasQc = payload.skills.some(
522
+ (s) => s && typeof s === "object" && s.command === "/flo:qc-logo"
523
+ );
524
+ if (!hasQc) {
525
+ throw new McpError(
526
+ ErrorCode.InternalError,
527
+ "Skill routing payload missing /flo:qc-logo option."
528
+ );
529
+ }
530
+ }
531
+
532
+ function assertQcPayload(payload) {
533
+ if (!payload || payload.status !== "ok" || typeof payload.result !== "object") {
534
+ throw new McpError(
535
+ ErrorCode.InternalError,
536
+ "QC payload did not match expected contract."
537
+ );
538
+ }
539
+ const result = payload.result || {};
540
+ const required = ["overall", "summary", "assetId", "assetUrl", "findings"];
541
+ for (const key of required) {
542
+ if (!(key in result)) {
543
+ throw new McpError(
544
+ ErrorCode.InternalError,
545
+ `QC payload missing result.${key}.`
546
+ );
547
+ }
548
+ }
549
+ }
550
+
551
+ async function probeInvocation(explicitAuthToken) {
552
+ let url;
553
+ try {
554
+ url = getInvocationUrl();
555
+ } catch (err) {
556
+ return {
557
+ configured: false,
558
+ invocationUrl: null,
559
+ reachable: false,
560
+ statusCode: null,
561
+ responsePreview:
562
+ err instanceof Error ? err.message : "Missing invocation URL",
563
+ };
564
+ }
565
+
566
+ try {
567
+ const response = await fetch(url, {
568
+ method: "POST",
569
+ headers: await buildHeaders(explicitAuthToken, false),
570
+ body: JSON.stringify({ prompt: "healthcheck" }),
571
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
572
+ });
573
+ const text = await response.text();
574
+ return {
575
+ configured: true,
576
+ invocationUrl: url,
577
+ reachable: true,
578
+ statusCode: response.status,
579
+ responsePreview: text.slice(0, 300),
580
+ };
581
+ } catch (err) {
582
+ return {
583
+ configured: true,
584
+ invocationUrl: url,
585
+ reachable: false,
586
+ statusCode: null,
587
+ responsePreview: err instanceof Error ? err.message : String(err),
588
+ };
589
+ }
590
+ }
591
+
592
+ const tools = [
593
+ {
594
+ name: "flo_auth_login",
595
+ description:
596
+ "Start OAuth login (browser PKCE flow), cache token locally, and return auth status.",
597
+ inputSchema: {
598
+ type: "object",
599
+ properties: {},
600
+ additionalProperties: false,
601
+ },
602
+ },
603
+ {
604
+ name: "flo_auth_status",
605
+ description: "Show current auth mode and cached OAuth token status.",
606
+ inputSchema: {
607
+ type: "object",
608
+ properties: {},
609
+ additionalProperties: false,
610
+ },
611
+ },
612
+ {
613
+ name: "flo_auth_setup_help",
614
+ description:
615
+ "Show where to find/configure FLO_OAUTH_CLIENT_ID and expected Cognito app client details.",
616
+ inputSchema: {
617
+ type: "object",
618
+ properties: {},
619
+ additionalProperties: false,
620
+ },
621
+ },
622
+ {
623
+ name: "flo_auth_logout",
624
+ description: "Clear cached OAuth token from local disk.",
625
+ inputSchema: {
626
+ type: "object",
627
+ properties: {},
628
+ additionalProperties: false,
629
+ },
630
+ },
631
+ {
632
+ name: "flo_search",
633
+ description:
634
+ "Run /flo:search against interface-agent and return plugin menu items.",
635
+ inputSchema: {
636
+ type: "object",
637
+ properties: {
638
+ query: { type: "string", minLength: 1 },
639
+ authToken: {
640
+ type: "string",
641
+ description: "Optional bearer token override for this call only.",
642
+ },
643
+ },
644
+ required: ["query"],
645
+ additionalProperties: false,
646
+ },
647
+ },
648
+ {
649
+ name: "flo_skill_routing",
650
+ description:
651
+ "Run /flo:skill-routing for an asset and return available plugin skills.",
652
+ inputSchema: {
653
+ type: "object",
654
+ properties: {
655
+ assetId: { type: "string", minLength: 1 },
656
+ authToken: {
657
+ type: "string",
658
+ description: "Optional bearer token override for this call only.",
659
+ },
660
+ },
661
+ required: ["assetId"],
662
+ additionalProperties: false,
663
+ },
664
+ },
665
+ {
666
+ name: "flo_qc_logo",
667
+ description:
668
+ "Run /flo:qc-logo using stored-image reference mode for the Claude plugin happy path.",
669
+ inputSchema: {
670
+ type: "object",
671
+ properties: {
672
+ assetId: { type: "string", minLength: 1 },
673
+ referenceAssetId: { type: "string", minLength: 1 },
674
+ includePdf: {
675
+ type: "boolean",
676
+ default: false,
677
+ description: "Whether to include PDF output in qc result.",
678
+ },
679
+ authToken: {
680
+ type: "string",
681
+ description: "Optional bearer token override for this call only.",
682
+ },
683
+ },
684
+ required: ["assetId", "referenceAssetId"],
685
+ additionalProperties: false,
686
+ },
687
+ },
688
+ {
689
+ name: "flo_command",
690
+ description:
691
+ "Run a raw /flo:* slash command against interface-agent for troubleshooting.",
692
+ inputSchema: {
693
+ type: "object",
694
+ properties: {
695
+ command: { type: "string", minLength: 1 },
696
+ authToken: {
697
+ type: "string",
698
+ description: "Optional bearer token override for this call only.",
699
+ },
700
+ },
701
+ required: ["command"],
702
+ additionalProperties: false,
703
+ },
704
+ },
705
+ {
706
+ name: "flo_plugin_healthcheck",
707
+ description:
708
+ "Check config/auth/runtime reachability for the Flo Claude Co-Work plugin.",
709
+ inputSchema: {
710
+ type: "object",
711
+ properties: {
712
+ authToken: {
713
+ type: "string",
714
+ description: "Optional bearer token override for this healthcheck.",
715
+ },
716
+ },
717
+ additionalProperties: false,
718
+ },
719
+ },
720
+ {
721
+ name: "flo_happy_path_run",
722
+ description:
723
+ "Run search -> skill routing -> qc logo in one tool call for end-to-end validation.",
724
+ inputSchema: {
725
+ type: "object",
726
+ properties: {
727
+ query: {
728
+ type: "string",
729
+ default: "hail mary",
730
+ description: "Search query used when assetId is not provided.",
731
+ },
732
+ assetId: {
733
+ type: "string",
734
+ description: "Optional preselected asset ID; skips search when set.",
735
+ },
736
+ referenceAssetId: {
737
+ type: "string",
738
+ minLength: 1,
739
+ description: "Stored image asset ID used as logo reference.",
740
+ },
741
+ includePdf: {
742
+ type: "boolean",
743
+ default: false,
744
+ description: "Whether to include PDF output in qc result.",
745
+ },
746
+ authToken: {
747
+ type: "string",
748
+ description: "Optional bearer token override for this run.",
749
+ },
750
+ },
751
+ required: ["referenceAssetId"],
752
+ additionalProperties: false,
753
+ },
754
+ },
755
+ ];
756
+
757
+ const server = new Server(
758
+ {
759
+ name: "flo-claude-plugin-mcp",
760
+ version: "0.1.0",
761
+ },
762
+ {
763
+ capabilities: {
764
+ tools: {},
765
+ },
766
+ }
767
+ );
768
+
769
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
770
+
771
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
772
+ const name = request.params.name;
773
+ const args = request.params.arguments || {};
774
+
775
+ if (name === "flo_auth_login") {
776
+ const token = await runInteractiveOAuthLogin();
777
+ return asTextResult({
778
+ status: "ok",
779
+ mode: "oauth",
780
+ expiresAt: token.expires_at,
781
+ cachePath: getTokenCachePath(),
782
+ });
783
+ }
784
+
785
+ if (name === "flo_auth_status") {
786
+ const oauth = getOAuthConfig(false);
787
+ const envToken = Boolean(getDefaultAuthToken());
788
+ const cachedToken = await readCachedToken();
789
+ return asTextResult({
790
+ oauthConfigured: oauth.ready,
791
+ oauthClientIdConfigured: Boolean(oauth.clientId),
792
+ oauthClientId: oauth.clientId || null,
793
+ oauthExpectedClientName: oauth.expectedClientName,
794
+ oauthUserPoolName: oauth.userPoolName,
795
+ oauthUserPoolId: oauth.userPoolId || null,
796
+ oauthSettingsUrl: oauth.settingsUrl,
797
+ envTokenConfigured: envToken,
798
+ cachePath: getTokenCachePath(),
799
+ cachedTokenPresent: Boolean(cachedToken?.access_token),
800
+ cachedTokenUsable: tokenIsUsable(cachedToken),
801
+ cachedTokenExpiresAt: cachedToken?.expires_at || null,
802
+ });
803
+ }
804
+
805
+ if (name === "flo_auth_setup_help") {
806
+ const oauth = getOAuthConfig(false);
807
+ return asTextResult({
808
+ status: "ok",
809
+ pluginEnv: normalizedPluginEnv(),
810
+ runtimeInvocationUrl: getInvocationUrl(),
811
+ oauthAuthorizeUrl: oauth.authorizeUrl || null,
812
+ oauthTokenUrl: oauth.tokenUrl || null,
813
+ oauthClientIdConfigured: Boolean(oauth.clientId),
814
+ oauthClientId: oauth.clientId || null,
815
+ oauthExpectedClientName: oauth.expectedClientName,
816
+ oauthUserPoolName: oauth.userPoolName,
817
+ oauthUserPoolId: oauth.userPoolId || null,
818
+ oauthSettingsUrl: oauth.settingsUrl,
819
+ message:
820
+ "Open oauthSettingsUrl, then copy the app client id for oauthExpectedClientName into FLO_OAUTH_CLIENT_ID.",
821
+ });
822
+ }
823
+
824
+ if (name === "flo_auth_logout") {
825
+ await clearCachedToken();
826
+ return asTextResult({
827
+ status: "ok",
828
+ message: "OAuth token cache cleared.",
829
+ cachePath: getTokenCachePath(),
830
+ });
831
+ }
832
+
833
+ if (name === "flo_search") {
834
+ const query = requireString(args.query, "query");
835
+ const prompt = `/flo:search ${query}`;
836
+ const bodyText = await invokeInterfaceAgent(prompt, args.authToken);
837
+ return asTextResult(parseMaybeJson(bodyText) || bodyText);
838
+ }
839
+
840
+ if (name === "flo_skill_routing") {
841
+ const assetId = requireString(args.assetId, "assetId");
842
+ const prompt = `/flo:skill-routing ${assetId}`;
843
+ const bodyText = await invokeInterfaceAgent(prompt, args.authToken);
844
+ return asTextResult(parseMaybeJson(bodyText) || bodyText);
845
+ }
846
+
847
+ if (name === "flo_qc_logo") {
848
+ const assetId = requireString(args.assetId, "assetId");
849
+ const referenceAssetId = requireString(args.referenceAssetId, "referenceAssetId");
850
+ if (args.includePdf !== undefined && typeof args.includePdf !== "boolean") {
851
+ throw new McpError(
852
+ ErrorCode.InvalidParams,
853
+ "`includePdf` must be a boolean when provided."
854
+ );
855
+ }
856
+ const payload = {
857
+ assetId,
858
+ reference: {
859
+ source: "stored_image",
860
+ referenceAssetId,
861
+ },
862
+ options: {
863
+ includePdf: Boolean(args.includePdf),
864
+ },
865
+ };
866
+ const prompt = `/flo:qc-logo ${JSON.stringify(payload)}`;
867
+ const bodyText = await invokeInterfaceAgent(prompt, args.authToken);
868
+ return asTextResult(parseMaybeJson(bodyText) || bodyText);
869
+ }
870
+
871
+ if (name === "flo_command") {
872
+ const command = requireString(args.command, "command");
873
+ const bodyText = await invokeInterfaceAgent(command, args.authToken);
874
+ return asTextResult(parseMaybeJson(bodyText) || bodyText);
875
+ }
876
+
877
+ if (name === "flo_plugin_healthcheck") {
878
+ const oauth = getOAuthConfig(false);
879
+ const envToken = Boolean(getDefaultAuthToken());
880
+ const cachedToken = await readCachedToken();
881
+ const probe = await probeInvocation(args.authToken);
882
+ return asTextResult({
883
+ status: "ok",
884
+ auth: {
885
+ oauthConfigured: oauth.ready,
886
+ envTokenConfigured: envToken,
887
+ cachePath: getTokenCachePath(),
888
+ cachedTokenPresent: Boolean(cachedToken?.access_token),
889
+ cachedTokenUsable: tokenIsUsable(cachedToken),
890
+ cachedTokenExpiresAt: cachedToken?.expires_at || null,
891
+ },
892
+ runtime: probe,
893
+ });
894
+ }
895
+
896
+ if (name === "flo_happy_path_run") {
897
+ const referenceAssetId = requireString(args.referenceAssetId, "referenceAssetId");
898
+ if (args.includePdf !== undefined && typeof args.includePdf !== "boolean") {
899
+ throw new McpError(
900
+ ErrorCode.InvalidParams,
901
+ "`includePdf` must be a boolean when provided."
902
+ );
903
+ }
904
+
905
+ let selectedAssetId =
906
+ typeof args.assetId === "string" && args.assetId.trim()
907
+ ? args.assetId.trim()
908
+ : "";
909
+ const query = typeof args.query === "string" && args.query.trim() ? args.query.trim() : "hail mary";
910
+ const steps = [];
911
+
912
+ if (!selectedAssetId) {
913
+ const searchPrompt = `/flo:search ${query}`;
914
+ const searchRaw = await invokeInterfaceAgent(searchPrompt, args.authToken);
915
+ const searchPayload = parseMaybeJson(searchRaw);
916
+ if (!searchPayload) {
917
+ throw new McpError(
918
+ ErrorCode.InternalError,
919
+ "Search response was not valid JSON during happy-path run."
920
+ );
921
+ }
922
+ selectedAssetId = assertSearchPayload(searchPayload);
923
+ steps.push({
924
+ name: "search",
925
+ query,
926
+ selectedAssetId,
927
+ response: searchPayload,
928
+ });
929
+ }
930
+
931
+ const skillPrompt = `/flo:skill-routing ${selectedAssetId}`;
932
+ const skillRaw = await invokeInterfaceAgent(skillPrompt, args.authToken);
933
+ const skillPayload = parseMaybeJson(skillRaw);
934
+ if (!skillPayload) {
935
+ throw new McpError(
936
+ ErrorCode.InternalError,
937
+ "Skill-routing response was not valid JSON during happy-path run."
938
+ );
939
+ }
940
+ assertSkillRoutingPayload(skillPayload);
941
+ steps.push({
942
+ name: "skill_routing",
943
+ response: skillPayload,
944
+ });
945
+
946
+ const qcCommandPayload = {
947
+ assetId: selectedAssetId,
948
+ reference: {
949
+ source: "stored_image",
950
+ referenceAssetId,
951
+ },
952
+ options: {
953
+ includePdf: Boolean(args.includePdf),
954
+ },
955
+ };
956
+ const qcPrompt = `/flo:qc-logo ${JSON.stringify(qcCommandPayload)}`;
957
+ const qcRaw = await invokeInterfaceAgent(qcPrompt, args.authToken);
958
+ const qcPayload = parseMaybeJson(qcRaw);
959
+ if (!qcPayload) {
960
+ throw new McpError(
961
+ ErrorCode.InternalError,
962
+ "QC response was not valid JSON during happy-path run."
963
+ );
964
+ }
965
+ assertQcPayload(qcPayload);
966
+ steps.push({
967
+ name: "qc_logo",
968
+ response: qcPayload,
969
+ });
970
+
971
+ return asTextResult({
972
+ status: "ok",
973
+ selectedAssetId,
974
+ steps,
975
+ });
976
+ }
977
+
978
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
979
+ });
980
+
981
+ const transport = new StdioServerTransport();
982
+ await server.connect(transport);