applaunchflow 0.3.7 → 0.3.9
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 +43 -0
- package/build/api-client.test.js +32 -0
- package/build/cli-core.js +25 -0
- package/build/cli-core.test.js +31 -1
- package/build/cli.js +38 -1
- package/build/cli.test.js +57 -0
- package/build/client/api.js +13 -0
- package/build/http.js +15 -1
- package/build/http.test.js +13 -0
- package/build/index.js +3 -1
- package/build/request-context.js +13 -0
- package/build/tool-metadata.js +30 -1
- package/build/tool-metadata.test.js +56 -0
- package/build/tools/assets.js +2 -1
- package/build/tools/screenshots.js +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,23 @@ codex mcp get applaunchflow
|
|
|
31
31
|
codex mcp remove applaunchflow
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
### Claude Code
|
|
35
|
+
|
|
36
|
+
The same one-command setup is available for Claude Code. It replaces a legacy
|
|
37
|
+
AppLaunchFlow entry when necessary, adds the hosted HTTP connector, and opens
|
|
38
|
+
OAuth sign-in:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx -y applaunchflow connect claude
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The equivalent native Claude Code commands are:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
claude mcp add --transport http applaunchflow https://mcp.applaunchflow.com/mcp
|
|
48
|
+
claude mcp login applaunchflow
|
|
49
|
+
```
|
|
50
|
+
|
|
34
51
|
### ChatGPT
|
|
35
52
|
|
|
36
53
|
```bash
|
|
@@ -48,6 +65,22 @@ Use this Streamable HTTP endpoint and enable OAuth when prompted:
|
|
|
48
65
|
https://mcp.applaunchflow.com/mcp
|
|
49
66
|
```
|
|
50
67
|
|
|
68
|
+
### Claude Code plugin
|
|
69
|
+
|
|
70
|
+
This repository is also a distributable Claude Code plugin. Its
|
|
71
|
+
`.claude-plugin/plugin.json` manifest bundles the hosted OAuth connector from
|
|
72
|
+
`.mcp.json`, so users do not need to copy a server configuration manually.
|
|
73
|
+
|
|
74
|
+
To validate or try the plugin directly from a clone:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
claude plugin validate . --strict
|
|
78
|
+
claude --plugin-dir .
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Claude Code starts the hosted connector when the plugin is enabled and opens
|
|
82
|
+
the AppLaunchFlow OAuth flow when authentication is required.
|
|
83
|
+
|
|
51
84
|
## Hosted service
|
|
52
85
|
|
|
53
86
|
The public Streamable HTTP service uses OAuth 2.1 authorization code flow with
|
|
@@ -68,6 +101,16 @@ Public endpoints:
|
|
|
68
101
|
- Protected resource metadata: `https://mcp.applaunchflow.com/.well-known/oauth-protected-resource`
|
|
69
102
|
- Health: `https://mcp.applaunchflow.com/healthz`
|
|
70
103
|
|
|
104
|
+
## Official MCP Registry
|
|
105
|
+
|
|
106
|
+
AppLaunchFlow is published as `io.github.ynnickw/applaunchflow` in the official
|
|
107
|
+
MCP Registry. The checked-in [`server.json`](server.json) is the canonical
|
|
108
|
+
registry manifest and points clients to the hosted OAuth connector.
|
|
109
|
+
|
|
110
|
+
Registry publication runs automatically from GitHub Actions when the manifest
|
|
111
|
+
changes on `main`. Keep the manifest version aligned with `package.json`; the
|
|
112
|
+
test suite enforces this before publication.
|
|
113
|
+
|
|
71
114
|
`APPLAUNCHFLOW_MCP_PUBLIC_URL` may be either the origin or the full `/mcp`
|
|
72
115
|
URL; both services normalize it to the same canonical resource URL. Set
|
|
73
116
|
`APPLAUNCHFLOW_MCP_PUBLIC_URL=https://mcp.applaunchflow.com/mcp` and
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { AppLaunchFlowClient } from "./client/api.js";
|
|
5
|
+
import { runWithRequestSignal } from "./request-context.js";
|
|
6
|
+
async function withUnresponsiveServer(callback) {
|
|
7
|
+
const server = createServer(() => undefined);
|
|
8
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
9
|
+
const address = server.address();
|
|
10
|
+
try {
|
|
11
|
+
await callback(`http://127.0.0.1:${address.port}`);
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
server.closeAllConnections();
|
|
15
|
+
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
test("dashboard requests honor explicit timeouts", async () => {
|
|
19
|
+
await withUnresponsiveServer(async (baseUrl) => {
|
|
20
|
+
const client = new AppLaunchFlowClient({ baseUrl, token: "test-token" });
|
|
21
|
+
await assert.rejects(client.requestJson("/slow", { timeoutMs: 20 }), (error) => error instanceof Error && error.name === "TimeoutError");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
test("dashboard requests inherit MCP request cancellation", async () => {
|
|
25
|
+
await withUnresponsiveServer(async (baseUrl) => {
|
|
26
|
+
const client = new AppLaunchFlowClient({ baseUrl, token: "test-token" });
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const request = runWithRequestSignal(controller.signal, () => client.listProjects());
|
|
29
|
+
controller.abort();
|
|
30
|
+
await assert.rejects(request, (error) => error instanceof Error && error.name === "AbortError");
|
|
31
|
+
});
|
|
32
|
+
});
|
package/build/cli-core.js
CHANGED
|
@@ -30,3 +30,28 @@ export function isHostedCodexConfig(value) {
|
|
|
30
30
|
export function codexDisconnectArgs() {
|
|
31
31
|
return ["mcp", "remove", APPLAUNCHFLOW_MCP_NAME];
|
|
32
32
|
}
|
|
33
|
+
export function claudeAddArgs() {
|
|
34
|
+
return [
|
|
35
|
+
"mcp",
|
|
36
|
+
"add",
|
|
37
|
+
"--transport",
|
|
38
|
+
"http",
|
|
39
|
+
APPLAUNCHFLOW_MCP_NAME,
|
|
40
|
+
APPLAUNCHFLOW_MCP_URL,
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
export function claudeLoginArgs() {
|
|
44
|
+
return ["mcp", "login", APPLAUNCHFLOW_MCP_NAME];
|
|
45
|
+
}
|
|
46
|
+
export function claudeStatusArgs() {
|
|
47
|
+
return ["mcp", "get", APPLAUNCHFLOW_MCP_NAME];
|
|
48
|
+
}
|
|
49
|
+
export function claudeDisconnectArgs() {
|
|
50
|
+
return ["mcp", "remove", APPLAUNCHFLOW_MCP_NAME];
|
|
51
|
+
}
|
|
52
|
+
export function isHostedClaudeConfig(output) {
|
|
53
|
+
const normalized = output.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "");
|
|
54
|
+
const hasHttpTransport = /^\s*Type:\s*http\s*$/im.test(normalized);
|
|
55
|
+
const configuredUrl = normalized.match(/^\s*URL:\s*(\S+)\s*$/im)?.[1];
|
|
56
|
+
return hasHttpTransport && configuredUrl === APPLAUNCHFLOW_MCP_URL;
|
|
57
|
+
}
|
package/build/cli-core.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { APPLAUNCHFLOW_MCP_NAME, APPLAUNCHFLOW_MCP_URL, codexAddArgs, codexDisconnectArgs, codexInspectArgs, codexLoginArgs, codexStatusArgs, isHostedCodexConfig, } from "./cli-core.js";
|
|
3
|
+
import { APPLAUNCHFLOW_MCP_NAME, APPLAUNCHFLOW_MCP_URL, claudeAddArgs, claudeDisconnectArgs, claudeLoginArgs, claudeStatusArgs, codexAddArgs, codexDisconnectArgs, codexInspectArgs, codexLoginArgs, codexStatusArgs, isHostedClaudeConfig, isHostedCodexConfig, } from "./cli-core.js";
|
|
4
4
|
test("Codex convenience commands use the hosted OAuth connector", () => {
|
|
5
5
|
assert.deepEqual(codexAddArgs(), [
|
|
6
6
|
"mcp",
|
|
@@ -23,6 +23,36 @@ test("Codex convenience commands use the hosted OAuth connector", () => {
|
|
|
23
23
|
"applaunchflow",
|
|
24
24
|
]);
|
|
25
25
|
});
|
|
26
|
+
test("Claude Code convenience commands use the hosted OAuth connector", () => {
|
|
27
|
+
assert.deepEqual(claudeAddArgs(), [
|
|
28
|
+
"mcp",
|
|
29
|
+
"add",
|
|
30
|
+
"--transport",
|
|
31
|
+
"http",
|
|
32
|
+
APPLAUNCHFLOW_MCP_NAME,
|
|
33
|
+
APPLAUNCHFLOW_MCP_URL,
|
|
34
|
+
]);
|
|
35
|
+
assert.deepEqual(claudeLoginArgs(), ["mcp", "login", "applaunchflow"]);
|
|
36
|
+
assert.deepEqual(claudeStatusArgs(), ["mcp", "get", "applaunchflow"]);
|
|
37
|
+
assert.deepEqual(claudeDisconnectArgs(), [
|
|
38
|
+
"mcp",
|
|
39
|
+
"remove",
|
|
40
|
+
"applaunchflow",
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
test("detects whether Claude Code already uses the hosted connector", () => {
|
|
44
|
+
assert.equal(isHostedClaudeConfig(`applaunchflow:
|
|
45
|
+
Scope: Local config
|
|
46
|
+
Type: http
|
|
47
|
+
URL: ${APPLAUNCHFLOW_MCP_URL}`), true);
|
|
48
|
+
assert.equal(isHostedClaudeConfig(`applaunchflow:
|
|
49
|
+
Type: stdio
|
|
50
|
+
Command: npx -y @applaunchflow/mcp@latest`), false);
|
|
51
|
+
assert.equal(isHostedClaudeConfig(`applaunchflow:
|
|
52
|
+
Type: http
|
|
53
|
+
URL: https://example.com/mcp`), false);
|
|
54
|
+
assert.equal(isHostedClaudeConfig(`\u001b[32mType: http\u001b[0m\nURL: ${APPLAUNCHFLOW_MCP_URL}`), true);
|
|
55
|
+
});
|
|
26
56
|
test("detects whether Codex already uses the hosted connector", () => {
|
|
27
57
|
assert.equal(isHostedCodexConfig({
|
|
28
58
|
transport: {
|
package/build/cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { APPLAUNCHFLOW_MCP_URL, codexAddArgs, codexDisconnectArgs, codexInspectArgs, codexLoginArgs, codexStatusArgs, isHostedCodexConfig, } from "./cli-core.js";
|
|
3
|
+
import { APPLAUNCHFLOW_MCP_URL, claudeAddArgs, claudeDisconnectArgs, claudeLoginArgs, claudeStatusArgs, codexAddArgs, codexDisconnectArgs, codexInspectArgs, codexLoginArgs, codexStatusArgs, isHostedClaudeConfig, isHostedCodexConfig, } from "./cli-core.js";
|
|
4
4
|
function printHelp() {
|
|
5
5
|
console.log(`AppLaunchFlow MCP
|
|
6
6
|
|
|
7
7
|
Usage:
|
|
8
8
|
applaunchflow connect codex
|
|
9
|
+
applaunchflow connect claude
|
|
9
10
|
applaunchflow connect chatgpt
|
|
10
11
|
applaunchflow status
|
|
11
12
|
applaunchflow disconnect
|
|
@@ -20,6 +21,15 @@ function runCodex(args) {
|
|
|
20
21
|
throw new Error(`Codex exited with status ${result.status ?? "unknown"}`);
|
|
21
22
|
}
|
|
22
23
|
}
|
|
24
|
+
function runClaude(args) {
|
|
25
|
+
const result = spawnSync("claude", args, { stdio: "inherit" });
|
|
26
|
+
if (result.error) {
|
|
27
|
+
throw new Error(`Could not run Claude Code: ${result.error.message}`);
|
|
28
|
+
}
|
|
29
|
+
if (result.status !== 0) {
|
|
30
|
+
throw new Error(`Claude Code exited with status ${result.status ?? "unknown"}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
23
33
|
function getCodexConfiguration() {
|
|
24
34
|
const result = spawnSync("codex", codexInspectArgs(), {
|
|
25
35
|
encoding: "utf8",
|
|
@@ -51,6 +61,29 @@ function connectCodex() {
|
|
|
51
61
|
}
|
|
52
62
|
runCodex(codexLoginArgs());
|
|
53
63
|
}
|
|
64
|
+
function getClaudeConfiguration() {
|
|
65
|
+
const result = spawnSync("claude", claudeStatusArgs(), {
|
|
66
|
+
encoding: "utf8",
|
|
67
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
68
|
+
});
|
|
69
|
+
if (result.error) {
|
|
70
|
+
throw new Error(`Could not run Claude Code: ${result.error.message}`);
|
|
71
|
+
}
|
|
72
|
+
if (result.status !== 0)
|
|
73
|
+
return "missing";
|
|
74
|
+
return isHostedClaudeConfig(result.stdout) ? "hosted" : "legacy";
|
|
75
|
+
}
|
|
76
|
+
function connectClaude() {
|
|
77
|
+
const configuration = getClaudeConfiguration();
|
|
78
|
+
if (configuration === "legacy") {
|
|
79
|
+
console.log("Replacing the legacy local AppLaunchFlow MCP configuration...");
|
|
80
|
+
runClaude(claudeDisconnectArgs());
|
|
81
|
+
}
|
|
82
|
+
if (configuration !== "hosted") {
|
|
83
|
+
runClaude(claudeAddArgs());
|
|
84
|
+
}
|
|
85
|
+
runClaude(claudeLoginArgs());
|
|
86
|
+
}
|
|
54
87
|
function connectChatGpt() {
|
|
55
88
|
console.log(`Add a custom MCP connector in ChatGPT using this URL:\n${APPLAUNCHFLOW_MCP_URL}`);
|
|
56
89
|
}
|
|
@@ -60,6 +93,10 @@ function main() {
|
|
|
60
93
|
connectCodex();
|
|
61
94
|
return;
|
|
62
95
|
}
|
|
96
|
+
if (command === "connect" && target === "claude") {
|
|
97
|
+
connectClaude();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
63
100
|
if (command === "connect" && target === "chatgpt") {
|
|
64
101
|
connectChatGpt();
|
|
65
102
|
return;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { APPLAUNCHFLOW_MCP_URL } from "./cli-core.js";
|
|
9
|
+
async function runConnectClaude(getOutput, getStatus = 0) {
|
|
10
|
+
const directory = await mkdtemp(join(tmpdir(), "applaunchflow-cli-"));
|
|
11
|
+
const executable = join(directory, "claude");
|
|
12
|
+
const logPath = join(directory, "claude.log");
|
|
13
|
+
await writeFile(executable, `#!/bin/sh
|
|
14
|
+
printf '%s\\n' "$*" >> "$CLAUDE_TEST_LOG"
|
|
15
|
+
if [ "$*" = "mcp get applaunchflow" ]; then
|
|
16
|
+
printf '%s' "$CLAUDE_GET_OUTPUT"
|
|
17
|
+
exit "$CLAUDE_GET_STATUS"
|
|
18
|
+
fi
|
|
19
|
+
`);
|
|
20
|
+
await chmod(executable, 0o755);
|
|
21
|
+
try {
|
|
22
|
+
const cliPath = fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
23
|
+
const result = spawnSync(process.execPath, [cliPath, "connect", "claude"], {
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
env: {
|
|
26
|
+
...process.env,
|
|
27
|
+
PATH: `${directory}:${process.env.PATH ?? ""}`,
|
|
28
|
+
CLAUDE_TEST_LOG: logPath,
|
|
29
|
+
CLAUDE_GET_OUTPUT: getOutput,
|
|
30
|
+
CLAUDE_GET_STATUS: String(getStatus),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
assert.equal(result.status, 0, result.stderr);
|
|
34
|
+
return (await readFile(logPath, "utf8")).trim().split("\n");
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await rm(directory, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
test("Claude helper adds a missing connector and starts OAuth", async () => {
|
|
41
|
+
assert.deepEqual(await runConnectClaude("", 1), [
|
|
42
|
+
"mcp get applaunchflow",
|
|
43
|
+
`mcp add --transport http applaunchflow ${APPLAUNCHFLOW_MCP_URL}`,
|
|
44
|
+
"mcp login applaunchflow",
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
test("Claude helper replaces a legacy connector before starting OAuth", async () => {
|
|
48
|
+
assert.deepEqual(await runConnectClaude("Type: stdio\nCommand: npx legacy-server"), [
|
|
49
|
+
"mcp get applaunchflow",
|
|
50
|
+
"mcp remove applaunchflow",
|
|
51
|
+
`mcp add --transport http applaunchflow ${APPLAUNCHFLOW_MCP_URL}`,
|
|
52
|
+
"mcp login applaunchflow",
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
test("Claude helper keeps the hosted connector and refreshes OAuth", async () => {
|
|
56
|
+
assert.deepEqual(await runConnectClaude(`Type: http\nURL: ${APPLAUNCHFLOW_MCP_URL}\nStatus: Connected`), ["mcp get applaunchflow", "mcp login applaunchflow"]);
|
|
57
|
+
});
|
package/build/client/api.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { upstreamSignal } from "../request-context.js";
|
|
2
|
+
const DEFAULT_API_TIMEOUT_MS = 30_000;
|
|
3
|
+
const LONG_RUNNING_API_TIMEOUT_MS = 10 * 60_000;
|
|
4
|
+
const UPLOAD_TIMEOUT_MS = 2 * 60_000;
|
|
1
5
|
export class AppLaunchFlowApiError extends Error {
|
|
2
6
|
status;
|
|
3
7
|
body;
|
|
@@ -45,6 +49,7 @@ export class AppLaunchFlowClient {
|
|
|
45
49
|
const response = await fetch(url, {
|
|
46
50
|
method: options.method || "GET",
|
|
47
51
|
headers,
|
|
52
|
+
signal: upstreamSignal(options.timeoutMs ?? DEFAULT_API_TIMEOUT_MS, options.signal),
|
|
48
53
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
49
54
|
});
|
|
50
55
|
const contentType = response.headers.get("content-type") || "";
|
|
@@ -74,6 +79,7 @@ export class AppLaunchFlowClient {
|
|
|
74
79
|
"Content-Type": contentType,
|
|
75
80
|
},
|
|
76
81
|
body: new Uint8Array(buffer),
|
|
82
|
+
signal: upstreamSignal(UPLOAD_TIMEOUT_MS),
|
|
77
83
|
});
|
|
78
84
|
if (!response.ok) {
|
|
79
85
|
throw new Error(`Upload failed with status ${response.status}`);
|
|
@@ -106,6 +112,7 @@ export class AppLaunchFlowClient {
|
|
|
106
112
|
return this.requestJson("/api/screenshots/generate", {
|
|
107
113
|
method: "POST",
|
|
108
114
|
body,
|
|
115
|
+
timeoutMs: LONG_RUNNING_API_TIMEOUT_MS,
|
|
109
116
|
});
|
|
110
117
|
}
|
|
111
118
|
applyScreenshotTemplate(body) {
|
|
@@ -163,6 +170,7 @@ export class AppLaunchFlowClient {
|
|
|
163
170
|
return this.requestJson("/api/promovideo/generate", {
|
|
164
171
|
method: "POST",
|
|
165
172
|
body,
|
|
173
|
+
timeoutMs: LONG_RUNNING_API_TIMEOUT_MS,
|
|
166
174
|
});
|
|
167
175
|
}
|
|
168
176
|
updatePromoVideo(body) {
|
|
@@ -214,6 +222,7 @@ export class AppLaunchFlowClient {
|
|
|
214
222
|
return this.requestJson("/api/screenshots/translate", {
|
|
215
223
|
method: "POST",
|
|
216
224
|
body,
|
|
225
|
+
timeoutMs: LONG_RUNNING_API_TIMEOUT_MS,
|
|
217
226
|
});
|
|
218
227
|
}
|
|
219
228
|
listVariants(generationId, contentType) {
|
|
@@ -257,6 +266,7 @@ export class AppLaunchFlowClient {
|
|
|
257
266
|
return this.requestJson("/api/graphics/generate", {
|
|
258
267
|
method: "POST",
|
|
259
268
|
body,
|
|
269
|
+
timeoutMs: LONG_RUNNING_API_TIMEOUT_MS,
|
|
260
270
|
});
|
|
261
271
|
}
|
|
262
272
|
applyGraphicsTemplate(body) {
|
|
@@ -286,6 +296,7 @@ export class AppLaunchFlowClient {
|
|
|
286
296
|
return this.requestJson("/api/aso/copy", {
|
|
287
297
|
method: "POST",
|
|
288
298
|
body,
|
|
299
|
+
timeoutMs: LONG_RUNNING_API_TIMEOUT_MS,
|
|
289
300
|
});
|
|
290
301
|
}
|
|
291
302
|
updateAsoCopy(body) {
|
|
@@ -298,12 +309,14 @@ export class AppLaunchFlowClient {
|
|
|
298
309
|
return this.requestJson("/api/aso/translate", {
|
|
299
310
|
method: "POST",
|
|
300
311
|
body,
|
|
312
|
+
timeoutMs: LONG_RUNNING_API_TIMEOUT_MS,
|
|
301
313
|
});
|
|
302
314
|
}
|
|
303
315
|
suggestCompetitors(body) {
|
|
304
316
|
return this.requestJson("/api/aso/competitors/suggest", {
|
|
305
317
|
method: "POST",
|
|
306
318
|
body,
|
|
319
|
+
timeoutMs: LONG_RUNNING_API_TIMEOUT_MS,
|
|
307
320
|
});
|
|
308
321
|
}
|
|
309
322
|
listSharedIllustrations(query) {
|
package/build/http.js
CHANGED
|
@@ -3,7 +3,9 @@ import { createServer } from "node:http";
|
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
5
|
import { createAppLaunchFlowServer } from "./index.js";
|
|
6
|
+
import { upstreamSignal } from "./request-context.js";
|
|
6
7
|
const DEFAULT_PORT = 8787;
|
|
8
|
+
const INTROSPECTION_TIMEOUT_MS = 10_000;
|
|
7
9
|
const DEFAULT_DASHBOARD_URL = "https://dashboard.applaunchflow.com";
|
|
8
10
|
const REQUIRED_SCOPES = [
|
|
9
11
|
"projects:read",
|
|
@@ -63,8 +65,16 @@ function unauthorized(request, response) {
|
|
|
63
65
|
"www-authenticate": `Bearer resource_metadata="${resourceMetadataUrl(request)}"`,
|
|
64
66
|
});
|
|
65
67
|
}
|
|
68
|
+
function methodNotAllowed(response) {
|
|
69
|
+
json(response, 405, {
|
|
70
|
+
jsonrpc: "2.0",
|
|
71
|
+
error: { code: -32000, message: "Method not allowed" },
|
|
72
|
+
id: null,
|
|
73
|
+
}, { allow: "POST" });
|
|
74
|
+
}
|
|
66
75
|
async function introspectToken(request, token) {
|
|
67
76
|
const response = await fetch(`${dashboardBaseUrl()}/api/auth/mcp/introspect`, {
|
|
77
|
+
signal: upstreamSignal(INTROSPECTION_TIMEOUT_MS),
|
|
68
78
|
headers: {
|
|
69
79
|
authorization: `Bearer ${token}`,
|
|
70
80
|
accept: "application/json",
|
|
@@ -113,6 +123,10 @@ async function handleMcp(request, response) {
|
|
|
113
123
|
unauthorized(request, response);
|
|
114
124
|
return;
|
|
115
125
|
}
|
|
126
|
+
if (request.method !== "POST") {
|
|
127
|
+
methodNotAllowed(response);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
116
130
|
const server = createAppLaunchFlowServer({
|
|
117
131
|
baseUrl: dashboardBaseUrl(),
|
|
118
132
|
token,
|
|
@@ -184,7 +198,7 @@ async function main() {
|
|
|
184
198
|
const port = Number(process.env.PORT || DEFAULT_PORT);
|
|
185
199
|
const server = createHttpServer();
|
|
186
200
|
server.listen(port, "0.0.0.0", () => {
|
|
187
|
-
console.
|
|
201
|
+
console.log(`AppLaunchFlow MCP HTTP server listening on port ${port}`);
|
|
188
202
|
});
|
|
189
203
|
}
|
|
190
204
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
package/build/http.test.js
CHANGED
|
@@ -116,6 +116,19 @@ test("authenticated Streamable HTTP clients can initialize and discover tools",
|
|
|
116
116
|
try {
|
|
117
117
|
await withServer(async (baseUrl) => {
|
|
118
118
|
process.env.APPLAUNCHFLOW_MCP_PUBLIC_URL = `${baseUrl}/mcp`;
|
|
119
|
+
for (const method of ["GET", "DELETE"]) {
|
|
120
|
+
const response = await fetch(`${baseUrl}/mcp`, {
|
|
121
|
+
method,
|
|
122
|
+
headers: { authorization: "Bearer test-access-token" },
|
|
123
|
+
});
|
|
124
|
+
assert.equal(response.status, 405);
|
|
125
|
+
assert.equal(response.headers.get("allow"), "POST");
|
|
126
|
+
assert.deepEqual(await response.json(), {
|
|
127
|
+
jsonrpc: "2.0",
|
|
128
|
+
error: { code: -32000, message: "Method not allowed" },
|
|
129
|
+
id: null,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
119
132
|
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), {
|
|
120
133
|
requestInit: {
|
|
121
134
|
headers: { authorization: "Bearer test-access-token" },
|
package/build/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import { AppLaunchFlowClient, } from "./client/api.js";
|
|
3
4
|
import { registerPrompts } from "./prompts/register.js";
|
|
4
5
|
import { registerResources } from "./resources/register.js";
|
|
@@ -14,6 +15,7 @@ import { registerLocalizationTools } from "./tools/localization.js";
|
|
|
14
15
|
import { registerVariantTools } from "./tools/variants.js";
|
|
15
16
|
import { registerKeywordTools } from "./tools/keywords.js";
|
|
16
17
|
import { installToolMetadataPolicy } from "./tool-metadata.js";
|
|
18
|
+
const packageJson = createRequire(import.meta.url)("../package.json");
|
|
17
19
|
export const SERVER_INSTRUCTIONS = `
|
|
18
20
|
AppLaunchFlow MCP supports four content types: app store screenshots, social graphics, promo videos, and mockup animations.
|
|
19
21
|
Use it for project setup, screenshot uploads, AI generation of screenshots/graphics/videos, mockup animation editing, variant management, direct layout editing, and translation.
|
|
@@ -106,7 +108,7 @@ export function createAppLaunchFlowServer(credentials) {
|
|
|
106
108
|
const client = new AppLaunchFlowClient(credentials);
|
|
107
109
|
const server = new McpServer({
|
|
108
110
|
name: "applaunchflow-mcp",
|
|
109
|
-
version:
|
|
111
|
+
version: packageJson.version,
|
|
110
112
|
}, {
|
|
111
113
|
instructions: HOSTED_SERVER_INSTRUCTIONS,
|
|
112
114
|
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
const requestSignalStorage = new AsyncLocalStorage();
|
|
3
|
+
export function runWithRequestSignal(signal, callback) {
|
|
4
|
+
return signal ? requestSignalStorage.run(signal, callback) : callback();
|
|
5
|
+
}
|
|
6
|
+
export function upstreamSignal(timeoutMs, explicitSignal) {
|
|
7
|
+
const signals = [
|
|
8
|
+
explicitSignal,
|
|
9
|
+
requestSignalStorage.getStore(),
|
|
10
|
+
AbortSignal.timeout(timeoutMs),
|
|
11
|
+
].filter((signal) => signal !== undefined);
|
|
12
|
+
return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
|
|
13
|
+
}
|
package/build/tool-metadata.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { runWithRequestSignal } from "./request-context.js";
|
|
2
3
|
const readOnly = {
|
|
3
4
|
readOnlyHint: true,
|
|
4
5
|
destructiveHint: false,
|
|
@@ -94,6 +95,34 @@ export function installToolMetadataPolicy(server, options = {}) {
|
|
|
94
95
|
throw new Error(`Missing tool safety annotations for ${name}`);
|
|
95
96
|
}
|
|
96
97
|
const existingMeta = (config._meta || {});
|
|
98
|
+
const toolCallback = callback;
|
|
99
|
+
const instrumentedCallback = async (...args) => {
|
|
100
|
+
const startedAt = performance.now();
|
|
101
|
+
const extra = args[1];
|
|
102
|
+
try {
|
|
103
|
+
const result = await runWithRequestSignal(extra?.signal, () => toolCallback(...args));
|
|
104
|
+
const isError = typeof result === "object" &&
|
|
105
|
+
result !== null &&
|
|
106
|
+
result.isError === true;
|
|
107
|
+
console.log(JSON.stringify({
|
|
108
|
+
event: "mcp_tool",
|
|
109
|
+
tool: name,
|
|
110
|
+
outcome: isError ? "error" : "success",
|
|
111
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
112
|
+
}));
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
console.error(JSON.stringify({
|
|
117
|
+
event: "mcp_tool",
|
|
118
|
+
tool: name,
|
|
119
|
+
outcome: "exception",
|
|
120
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
121
|
+
errorType: error instanceof Error ? error.name : "UnknownError",
|
|
122
|
+
}));
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
97
126
|
return registerTool(name, {
|
|
98
127
|
...config,
|
|
99
128
|
annotations: {
|
|
@@ -107,6 +136,6 @@ export function installToolMetadataPolicy(server, options = {}) {
|
|
|
107
136
|
? { securitySchemes: [OAUTH_SECURITY_SCHEME] }
|
|
108
137
|
: {}),
|
|
109
138
|
},
|
|
110
|
-
},
|
|
139
|
+
}, instrumentedCallback);
|
|
111
140
|
});
|
|
112
141
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
2
4
|
import test from "node:test";
|
|
3
5
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
6
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
@@ -24,9 +26,63 @@ test("all registered tools expose submission safety metadata and output schemas"
|
|
|
24
26
|
assert.ok(tool.description, `${tool.name} must have a description`);
|
|
25
27
|
assert.deepEqual(tool._meta?.securitySchemes?.map((scheme) => scheme.type), ["oauth2"]);
|
|
26
28
|
}
|
|
29
|
+
assert.equal(client.getServerVersion()?.version, createRequire(import.meta.url)("../package.json")
|
|
30
|
+
.version);
|
|
27
31
|
}
|
|
28
32
|
finally {
|
|
29
33
|
await client.close();
|
|
30
34
|
await server.close();
|
|
31
35
|
}
|
|
32
36
|
});
|
|
37
|
+
test("hosted tools emit privacy-safe structured outcome logs", async () => {
|
|
38
|
+
const api = createServer((request, response) => {
|
|
39
|
+
if (request.url === "/api/projects") {
|
|
40
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
41
|
+
response.end(JSON.stringify({ projects: [] }));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (request.url?.startsWith("/api/app/")) {
|
|
45
|
+
response.writeHead(500, { "content-type": "application/json" });
|
|
46
|
+
response.end(JSON.stringify({ error: "synthetic backend failure" }));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
response.writeHead(404).end();
|
|
50
|
+
});
|
|
51
|
+
await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
|
|
52
|
+
const address = api.address();
|
|
53
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
54
|
+
const server = createAppLaunchFlowServer({
|
|
55
|
+
baseUrl: `http://127.0.0.1:${address.port}`,
|
|
56
|
+
token: "secret-test-token",
|
|
57
|
+
});
|
|
58
|
+
const client = new Client({ name: "logging-test", version: "1.0.0" });
|
|
59
|
+
const logs = [];
|
|
60
|
+
const originalLog = console.log;
|
|
61
|
+
console.log = (...args) => logs.push(args.map(String).join(" "));
|
|
62
|
+
try {
|
|
63
|
+
await server.connect(serverTransport);
|
|
64
|
+
await client.connect(clientTransport);
|
|
65
|
+
const result = await client.callTool({ name: "list_projects", arguments: {} });
|
|
66
|
+
assert.equal(result.isError, undefined);
|
|
67
|
+
const failed = await client.callTool({
|
|
68
|
+
name: "get_project",
|
|
69
|
+
arguments: { projectId: "00000000-0000-4000-8000-000000000001" },
|
|
70
|
+
});
|
|
71
|
+
assert.equal(failed.isError, true);
|
|
72
|
+
assert.equal(logs.length, 2);
|
|
73
|
+
const entries = logs.map((line) => JSON.parse(line));
|
|
74
|
+
assert.deepEqual(entries.map(({ event, tool, outcome }) => ({ event, tool, outcome })), [
|
|
75
|
+
{ event: "mcp_tool", tool: "list_projects", outcome: "success" },
|
|
76
|
+
{ event: "mcp_tool", tool: "get_project", outcome: "error" },
|
|
77
|
+
]);
|
|
78
|
+
assert.equal(entries.every((entry) => typeof entry.durationMs === "number"), true);
|
|
79
|
+
assert.equal(logs.some((line) => line.includes("secret-test-token")), false);
|
|
80
|
+
assert.equal(logs.some((line) => line.includes("synthetic backend failure")), false);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
console.log = originalLog;
|
|
84
|
+
await client.close();
|
|
85
|
+
await server.close();
|
|
86
|
+
await new Promise((resolve, reject) => api.close((error) => (error ? reject(error) : resolve())));
|
|
87
|
+
}
|
|
88
|
+
});
|
package/build/tools/assets.js
CHANGED
|
@@ -3,6 +3,7 @@ import { promises as dns } from "node:dns";
|
|
|
3
3
|
import { BlockList, isIP } from "node:net";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { upstreamSignal } from "../request-context.js";
|
|
6
7
|
import { fail, ok } from "./utils.js";
|
|
7
8
|
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
8
9
|
const MAX_REMOTE_REDIRECTS = 3;
|
|
@@ -87,7 +88,7 @@ async function fetchRemoteAsset(value) {
|
|
|
87
88
|
for (let redirectCount = 0; redirectCount <= MAX_REMOTE_REDIRECTS; redirectCount += 1) {
|
|
88
89
|
const response = await fetch(current, {
|
|
89
90
|
redirect: "manual",
|
|
90
|
-
signal:
|
|
91
|
+
signal: upstreamSignal(20_000),
|
|
91
92
|
headers: { accept: "image/*,font/*;q=0.8" },
|
|
92
93
|
});
|
|
93
94
|
if (response.status < 300 || response.status >= 400) {
|
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { listPublicTemplateIds } from "../catalog.js";
|
|
4
4
|
import { buildTemplateGalleryUrl } from "../template-previews.js";
|
|
5
5
|
import { openUrl, fail, ok, startProgressHeartbeat } from "./utils.js";
|
|
6
|
+
import { upstreamSignal } from "../request-context.js";
|
|
6
7
|
export function registerScreenshotTools(server, client) {
|
|
7
8
|
server.registerTool("prepare_screenshot_styles", {
|
|
8
9
|
title: "Prepare Personalized Screenshot Styles",
|
|
@@ -294,7 +295,10 @@ export function registerScreenshotTools(server, client) {
|
|
|
294
295
|
const previewUrl = `${client.credentials.baseUrl}/api/preview?path=${encodeURIComponent(fullPath)}&w=320`;
|
|
295
296
|
const headers = new Headers();
|
|
296
297
|
headers.set("Authorization", `Bearer ${client.credentials.token}`);
|
|
297
|
-
const response = await fetch(previewUrl, {
|
|
298
|
+
const response = await fetch(previewUrl, {
|
|
299
|
+
headers,
|
|
300
|
+
signal: upstreamSignal(30_000),
|
|
301
|
+
});
|
|
298
302
|
if (!response.ok) {
|
|
299
303
|
throw new Error(`Failed to fetch image: ${response.status}`);
|
|
300
304
|
}
|