@fastagent-sh/fastagent 0.28.0 → 0.29.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 +2 -1
- package/dist/channels/feishu/feishu-api.d.ts +1 -0
- package/dist/channels/feishu/feishu-api.js +4 -0
- package/dist/channels/feishu/register-app.d.ts +2 -0
- package/dist/channels/feishu/register-app.js +1 -1
- package/dist/channels/lark/credentials-page.d.ts +25 -0
- package/dist/channels/lark/credentials-page.js +206 -0
- package/dist/channels/lark/onboard.d.ts +18 -2
- package/dist/channels/lark/onboard.js +36 -6
- package/dist/channels/slack/setup-server.js +75 -30
- package/dist/channels/wait-health.js +1 -1
- package/dist/cli/add-feishu.d.ts +0 -9
- package/dist/cli/add-feishu.js +62 -38
- package/dist/cli/commands/add.js +5 -12
- package/dist/cli/commands/deploy.js +1 -8
- package/dist/cli/commands/info.js +4 -0
- package/dist/cli/commands/mcp.d.ts +12 -0
- package/dist/cli/commands/mcp.js +156 -0
- package/dist/cli/program.js +59 -0
- package/dist/deploy/agentcore/destroy.js +1 -1
- package/dist/deploy/build-context.js +4 -0
- package/dist/deploy/container.js +1 -2
- package/dist/deploy/docker/run.d.ts +16 -3
- package/dist/deploy/docker/run.js +54 -1
- package/dist/deploy/fly/run.d.ts +0 -14
- package/dist/deploy/fly/run.js +4 -5
- package/dist/dev-supervisor.js +3 -2
- package/dist/harnesses/pi-durable/mcp.d.ts +3 -0
- package/dist/harnesses/pi-durable/mcp.js +34 -0
- package/dist/harnesses/pi-durable/open.d.ts +1 -1
- package/dist/harnesses/pi-durable/open.js +12 -3
- package/dist/harnesses/pi-durable/serve.d.ts +4 -0
- package/dist/harnesses/pi-durable/serve.js +47 -17
- package/dist/http/contract.d.ts +3 -3
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/locked-file.d.ts +2 -2
- package/dist/locked-file.js +8 -3
- package/dist/mcp/auth.d.ts +22 -0
- package/dist/mcp/auth.js +180 -0
- package/dist/mcp/client.d.ts +15 -0
- package/dist/mcp/client.js +170 -0
- package/dist/mcp/config.d.ts +34 -0
- package/dist/mcp/config.js +142 -0
- package/dist/mcp/grants.d.ts +4 -0
- package/dist/mcp/grants.js +132 -0
- package/dist/mcp/host.d.ts +16 -0
- package/dist/mcp/host.js +120 -0
- package/dist/mcp/network.d.ts +12 -0
- package/dist/mcp/network.js +150 -0
- package/dist/mcp/sign-in.d.ts +2 -0
- package/dist/mcp/sign-in.js +204 -0
- package/dist/mcp-api.d.ts +93 -0
- package/dist/mcp-api.js +10 -0
- package/dist/setup-page.d.ts +51 -0
- package/dist/setup-page.js +149 -0
- package/dist/value-file.d.ts +1 -0
- package/dist/value-file.js +15 -16
- package/package.json +3 -2
package/dist/cli/add-feishu.js
CHANGED
|
@@ -7,9 +7,10 @@ import { isCancel, log as clackLog, password, text as clackText } from "@clack/p
|
|
|
7
7
|
import { bootstrapFeishuVerificationToken } from "../channels/feishu/bootstrap-token.js";
|
|
8
8
|
import { FEISHU_APP_CONFIG_SCOPE, feishuAppAddons, feishuAppScopes, feishuScopeRequestUrl, scopeSatisfied, } from "../channels/feishu/setup-mode.js";
|
|
9
9
|
import { cloudFor } from "../channels/feishu/cloud.js";
|
|
10
|
-
import { createFeishuApi, isFeishuConfigApiMissing, isTransientFeishuRegistrationError, } from "../channels/feishu/feishu-api.js";
|
|
10
|
+
import { createFeishuApi, isFeishuConfigApiMissing, isFeishuCredentialsRefused, isTransientFeishuRegistrationError, } from "../channels/feishu/feishu-api.js";
|
|
11
11
|
import { registerFeishuApp } from "../channels/feishu/register-app.js";
|
|
12
|
-
import {
|
|
12
|
+
import { LarkCredentialsRefused, startLarkCredentialsPage, startLarkVerificationPage, } from "../channels/lark/credentials-page.js";
|
|
13
|
+
import { onboardLarkApp } from "../channels/lark/onboard.js";
|
|
13
14
|
import { dotEnvPath, loadEnvValues, parseEnvContent } from "../value-file.js";
|
|
14
15
|
import { openExternalUrl } from "../open-url.js";
|
|
15
16
|
import { terminalPerson } from "./person.js";
|
|
@@ -88,7 +89,7 @@ person = terminalPerson) {
|
|
|
88
89
|
return undefined;
|
|
89
90
|
}
|
|
90
91
|
// guided-console (lark): the intl cloud cannot complete the bound device flow — collect + validate.
|
|
91
|
-
const credentials = await onboardLarkApp(person === terminalPerson ? larkAtTerminal : larkThrough(person,
|
|
92
|
+
const credentials = await onboardLarkApp(person === terminalPerson ? larkAtTerminal : larkThrough(person, target), {
|
|
92
93
|
existing,
|
|
93
94
|
ingress,
|
|
94
95
|
rerun,
|
|
@@ -99,6 +100,17 @@ person = terminalPerson) {
|
|
|
99
100
|
await createFeishuApi({ kind: "lark", baseUrl: apiBase, appId, appSecret }).verifyCredentials();
|
|
100
101
|
console.error(`[fastagent] Lark App ID / Secret verified`);
|
|
101
102
|
},
|
|
103
|
+
// The same boundary `add feishu` keeps: a pair given just now is written before any step that can fail, so a
|
|
104
|
+
// rerun finds it and carries on, instead of asking for it again.
|
|
105
|
+
keep: async (appId, appSecret) => {
|
|
106
|
+
const kept = {
|
|
107
|
+
LARK_APP_ID: appId,
|
|
108
|
+
LARK_APP_SECRET: appSecret,
|
|
109
|
+
...(ingress === "webhook" ? { LARK_VERIFICATION_TOKEN: "" } : {}),
|
|
110
|
+
};
|
|
111
|
+
await appendChannelDotEnv(target, "lark", kept, Object.keys(kept), ingress);
|
|
112
|
+
console.error(`[fastagent] wrote LARK_APP_ID, LARK_APP_SECRET to ${env}`);
|
|
113
|
+
},
|
|
102
114
|
bootstrapWebhook: async (appId, appSecret) => {
|
|
103
115
|
const api = createFeishuApi({ kind: "lark", baseUrl: apiBase, appId, appSecret });
|
|
104
116
|
console.error(`[fastagent] trying Lark's webhook-mode + Verification-Token bootstrap (temporary tunnel)…`);
|
|
@@ -152,16 +164,56 @@ const larkAtTerminal = {
|
|
|
152
164
|
return isCancel(result) ? undefined : result;
|
|
153
165
|
},
|
|
154
166
|
};
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
167
|
+
async function waitForLarkPage(person, page, action) {
|
|
168
|
+
const port = new URL(page.url).port;
|
|
169
|
+
try {
|
|
170
|
+
person.waitsOn(page.url, action +
|
|
171
|
+
" If the CLI runs remotely, forward this loopback port to the same local port " +
|
|
172
|
+
`(SSH: ssh -N -L ${port}:127.0.0.1:${port} user@host), then open the same URL in your browser. ` +
|
|
173
|
+
"Keep the forwarding session open; never paste secrets into the conversation.");
|
|
174
|
+
const value = await page.credentials;
|
|
175
|
+
person.carriesOn();
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
await page.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function larkThrough(person, target) {
|
|
160
183
|
return {
|
|
161
184
|
note: (message) => console.error(`[fastagent] ${message}`),
|
|
162
185
|
openUrl: (url, action) => person.afterwards(action, url),
|
|
163
|
-
async prompt(
|
|
164
|
-
throw new Error(
|
|
186
|
+
async prompt() {
|
|
187
|
+
throw new Error("Lark setup must collect secrets on its page, not through terminal prompts");
|
|
188
|
+
},
|
|
189
|
+
async verificationToken(consoleUrl) {
|
|
190
|
+
const page = await startLarkVerificationPage({
|
|
191
|
+
consoleUrl,
|
|
192
|
+
save: async (token) => {
|
|
193
|
+
await appendChannelDotEnv(target, "lark", { LARK_VERIFICATION_TOKEN: token }, ["LARK_VERIFICATION_TOKEN"]);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
return waitForLarkPage(person, page, "Paste the Lark Verification Token on this page.");
|
|
197
|
+
},
|
|
198
|
+
async collect({ consoleUrl, check }) {
|
|
199
|
+
const page = await startLarkCredentialsPage({
|
|
200
|
+
consoleUrl,
|
|
201
|
+
check: async ({ appId, appSecret }) => {
|
|
202
|
+
try {
|
|
203
|
+
await check(appId, appSecret);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
// Only a known credential refusal is safe to retry on the form.
|
|
207
|
+
if (!isFeishuCredentialsRefused(error))
|
|
208
|
+
throw error;
|
|
209
|
+
throw new LarkCredentialsRefused("Lark refused that App ID or Secret.", {
|
|
210
|
+
fix: "Copy both again from Credentials & Basic Info.",
|
|
211
|
+
why: `Lark said: ${error instanceof Error ? error.message : String(error)}`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
return waitForLarkPage(person, page, "Create the Lark app, then paste its App ID and App Secret on this page.");
|
|
165
217
|
},
|
|
166
218
|
};
|
|
167
219
|
}
|
|
@@ -280,34 +332,6 @@ async function createFeishuAppFlow(target, existing, ingress, rerun, person) {
|
|
|
280
332
|
person.afterwards(`after the permission work above, ${publish}: ${versionUrl}`);
|
|
281
333
|
}
|
|
282
334
|
}
|
|
283
|
-
/**
|
|
284
|
-
* Lark's app credentials, which only the person can get, from the app they create in Lark's console: with no one at a
|
|
285
|
-
* terminal to type them, the report naming where they set them (the selected environment's file), when either is
|
|
286
|
-
* absent. `again` is the command that continues once they have.
|
|
287
|
-
*/
|
|
288
|
-
export async function larkCredentialsNeeded(agentDir, again) {
|
|
289
|
-
const held = await activeDotEnvValues(agentDir, ["LARK_APP_ID", "LARK_APP_SECRET"]);
|
|
290
|
-
if (held.LARK_APP_ID && held.LARK_APP_SECRET)
|
|
291
|
-
return undefined;
|
|
292
|
-
const valueFile = dotEnvPath(agentDir);
|
|
293
|
-
return {
|
|
294
|
-
json: {
|
|
295
|
-
status: "need_user_action",
|
|
296
|
-
url: LARK_CONSOLE_URL,
|
|
297
|
-
variables: ["LARK_APP_ID", "LARK_APP_SECRET"],
|
|
298
|
-
value_file: valueFile,
|
|
299
|
-
check_command: `${again} --json`,
|
|
300
|
-
agent_next_steps: "Give the URL to the user so they can create the Lark app there, and ask them to set LARK_APP_ID and " +
|
|
301
|
-
`LARK_APP_SECRET (from its Credentials & Basic Info) in ${valueFile} themselves, not in this ` +
|
|
302
|
-
"conversation. Once they say they have, run check_command.",
|
|
303
|
-
},
|
|
304
|
-
text: [
|
|
305
|
-
`need_user_action: create the Lark app at ${LARK_CONSOLE_URL}`,
|
|
306
|
-
` and set LARK_APP_ID and LARK_APP_SECRET in ${valueFile}`,
|
|
307
|
-
`then run: ${again}`,
|
|
308
|
-
],
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
335
|
/**
|
|
312
336
|
* Active agent `.env` (`.secrets/.env`) values for the requested names — decided by THE .env parser, so this check can
|
|
313
337
|
* never disagree with what `loadDotEnv` reads.
|
package/dist/cli/commands/add.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
6
|
import { join, relative, resolve } from "node:path";
|
|
7
|
-
import { assertIndependentFeishuApps,
|
|
7
|
+
import { assertIndependentFeishuApps, onboardFeishuCloudApp } from "../add-feishu.js";
|
|
8
8
|
import { terminalPerson } from "../person.js";
|
|
9
9
|
import { isInteractive, shellArg } from "../shared.js";
|
|
10
10
|
import { checkTicket, printReport, refusedCheck, runTicket, startTicket, ticketExitCode, ticketReport, } from "../ticket.js";
|
|
@@ -211,20 +211,13 @@ const SETUP_ACTIONS = {
|
|
|
211
211
|
lark: "finish setting up the Lark app",
|
|
212
212
|
};
|
|
213
213
|
/**
|
|
214
|
-
* `add slack|feishu|lark` with no one at a terminal to ask, for a coding agent:
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
214
|
+
* `add slack|feishu|lark` with no one at a terminal to ask, for a coding agent: the app's setup runs in a process of its
|
|
215
|
+
* own (ticket.ts), and the command returns once the person has a step to take, it is done, or it is still working at it.
|
|
216
|
+
* What only the person has (Slack's tokens, Lark's App ID and Secret) is pasted on a page of the setup's own, never asked
|
|
217
|
+
* for in the agent's conversation and never typed into a file.
|
|
218
218
|
*/
|
|
219
219
|
async function setUpUnattended(kind, opts, at) {
|
|
220
220
|
const { target } = at;
|
|
221
|
-
if (kind === "lark") {
|
|
222
|
-
const needed = await larkCredentialsNeeded(target, at.rerun).catch(failStartup);
|
|
223
|
-
if (needed !== undefined) {
|
|
224
|
-
printReport(opts.json, needed.json, needed.text);
|
|
225
|
-
process.exit(0);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
221
|
const timeout = opts.timeout === undefined ? UNATTENDED_TIMEOUT_SECONDS : Number(opts.timeout);
|
|
229
222
|
if (!Number.isInteger(timeout) || timeout <= 0)
|
|
230
223
|
failUsage(`--timeout must be a whole number of seconds`);
|
|
@@ -8,7 +8,7 @@ import { FEISHU_INGRESS_SETTINGS } from "../../channels/feishu/setup-mode.js";
|
|
|
8
8
|
import { assembleSecrets, divergentSettingsGate, missingValuesGate } from "../../deploy/secrets.js";
|
|
9
9
|
import { dotEnvPath, loadEnvValues } from "../../value-file.js";
|
|
10
10
|
import { unmarkDevServe } from "../../serving-command.js";
|
|
11
|
-
import { assertIndependentFeishuApps, feishuAppSecretNames,
|
|
11
|
+
import { assertIndependentFeishuApps, feishuAppSecretNames, prepareFeishuApps, unpreparedFeishuApps, } from "../add-feishu.js";
|
|
12
12
|
import { displayPath, selectAgentEnvironment } from "../../paths.js";
|
|
13
13
|
import { preflightDeploy } from "../../deploy/preflight.js";
|
|
14
14
|
import { apiReport, deploymentApi, writeApiKey } from "../../deploy/api.js";
|
|
@@ -139,13 +139,6 @@ async function deploy(host, dirArg, opts, step) {
|
|
|
139
139
|
const unprepared = opts.run ? unpreparedFeishuApps(pre.channels, pre.values) : [];
|
|
140
140
|
if (opts.json && step === undefined) {
|
|
141
141
|
// A coding agent's deploy: what this machine can refuse is refused above, at once; the rest runs in a ticket.
|
|
142
|
-
if (unprepared.some(({ kind }) => kind === "lark")) {
|
|
143
|
-
const needed = await larkCredentialsNeeded(agentDir, rerun).catch(failStartup);
|
|
144
|
-
if (needed !== undefined) {
|
|
145
|
-
printReport(true, needed.json, needed.text);
|
|
146
|
-
process.exit(0);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
142
|
const timeout = opts.timeout === undefined ? TICKET_TIMEOUT_SECONDS : Number(opts.timeout);
|
|
150
143
|
if (!Number.isInteger(timeout) || timeout <= 0)
|
|
151
144
|
failUsage("--timeout must be a whole number of seconds");
|
|
@@ -8,6 +8,7 @@ import { enterAgentEnv } from "../../env.js";
|
|
|
8
8
|
import { modelEnvironment } from "../../harnesses/models/environment.js";
|
|
9
9
|
import { buildDefinition, modelSpecFor, prepareDefinition, secretsManifest } from "../../harnesses/pi-durable/open.js";
|
|
10
10
|
import { readCommands } from "../../harnesses/pi-durable/text.js";
|
|
11
|
+
import { inspectMcp } from "../../mcp/host.js";
|
|
11
12
|
import { resolveStateRoot } from "../../paths.js";
|
|
12
13
|
import { nextRun } from "../../schedule/cron.js";
|
|
13
14
|
import { contentLines } from "../content-view.js";
|
|
@@ -60,6 +61,7 @@ export async function runInfo(dirArg, opts) {
|
|
|
60
61
|
const commands = readCommands(agentDir).map(({ name, description }) => ({ name, description: description ?? null }));
|
|
61
62
|
const stateRoot = resolveStateRoot(agentDir);
|
|
62
63
|
const auth = models.auth;
|
|
64
|
+
const mcp = await inspectMcp(agentDir).catch(failStartup);
|
|
63
65
|
if (opts.json) {
|
|
64
66
|
console.log(JSON.stringify({
|
|
65
67
|
agentDir,
|
|
@@ -72,6 +74,7 @@ export async function runInfo(dirArg, opts) {
|
|
|
72
74
|
modelError: modelError ?? null,
|
|
73
75
|
thinkingLevel: config.thinkingLevel ?? null,
|
|
74
76
|
tools: code?.tools.map(({ name }) => name) ?? null,
|
|
77
|
+
mcp,
|
|
75
78
|
extensions: code?.extensions.map(({ name }) => name) ?? null,
|
|
76
79
|
channels: code === undefined ? null : channels,
|
|
77
80
|
schedules: code === undefined ? null : schedules,
|
|
@@ -102,6 +105,7 @@ export async function runInfo(dirArg, opts) {
|
|
|
102
105
|
line("tools", code ? code.tools.map(({ name }) => name).join(", ") || "(none)" : unbuilt);
|
|
103
106
|
if (code && code.extensions.length > 0)
|
|
104
107
|
line("extensions", code.extensions.map(({ name }) => name).join(", "));
|
|
108
|
+
line("mcp", mcp.map((server) => `${server.name} (${server.transport}, ${server.authorization})`).join(", ") || "(none)");
|
|
105
109
|
line("channels", code ? channels.map((c) => `${c.name} (${c.ingress})`).join(", ") || "(none)" : unbuilt);
|
|
106
110
|
line("schedules", code ? schedules.map((s) => `${s.name} (next ${s.next ?? "never"})`).join(", ") || "(none)" : unbuilt);
|
|
107
111
|
line("commands", commands.map(({ name }) => `/${name}`).join(", ") || "(none)");
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface McpCommandOptions {
|
|
2
|
+
json?: boolean;
|
|
3
|
+
environment?: string;
|
|
4
|
+
scopes?: string;
|
|
5
|
+
timeout?: string;
|
|
6
|
+
clientId?: string;
|
|
7
|
+
callbackPort?: string;
|
|
8
|
+
check?: string;
|
|
9
|
+
ticket?: string;
|
|
10
|
+
input?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function runMcp(action: "list" | "login" | "disconnect", server: string | undefined, dir: string, options: McpCommandOptions): Promise<void>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** MCP inspection, explicit sign-in and local binding removal. */
|
|
2
|
+
import { confirm, isCancel } from "@clack/prompts";
|
|
3
|
+
import { inspectMcp, mcpContext, writeMcpBinding, httpServer } from "../../mcp/host.js";
|
|
4
|
+
import { signInMcp } from "../../mcp/sign-in.js";
|
|
5
|
+
import { holdingInstance } from "../../harnesses/pi-durable/serve.js";
|
|
6
|
+
import { resolveMcpServers } from "../../mcp/config.js";
|
|
7
|
+
import { McpError } from "../../mcp-api.js";
|
|
8
|
+
import { selectAgentEnvironment } from "../../paths.js";
|
|
9
|
+
import { enterAgentEnv } from "../../env.js";
|
|
10
|
+
import { openExternalUrl } from "../../open-url.js";
|
|
11
|
+
import { isInteractive, shellArg } from "../shared.js";
|
|
12
|
+
import { agentDirOrExit, failStartup, failUsage } from "../fail.js";
|
|
13
|
+
import { checkTicket, refusedCheck, runTicket, startTicket, ticketExitCode, ticketReport, printReport, } from "../ticket.js";
|
|
14
|
+
const CHECK = "fastagent mcp login --check";
|
|
15
|
+
function report(ticket, json, when) {
|
|
16
|
+
const result = ticketReport(ticket, when, (value) => ({
|
|
17
|
+
fields: value,
|
|
18
|
+
text: ["MCP authorization saved and bound; reopen the agent to use it"],
|
|
19
|
+
next: "Reopen the agent to use its MCP tools.",
|
|
20
|
+
}));
|
|
21
|
+
printReport(json, result.json, result.text);
|
|
22
|
+
}
|
|
23
|
+
export async function runMcp(action, server, dir, options) {
|
|
24
|
+
if (options.check) {
|
|
25
|
+
const refusal = refusedCheck(options.check, CHECK);
|
|
26
|
+
if (refusal)
|
|
27
|
+
return failUsage(refusal);
|
|
28
|
+
const ticket = await checkTicket(options.check);
|
|
29
|
+
if (!ticket)
|
|
30
|
+
return failUsage("MCP sign-in ticket not found");
|
|
31
|
+
report(ticket, !!options.json, "checked");
|
|
32
|
+
process.exit(ticketExitCode(ticket));
|
|
33
|
+
}
|
|
34
|
+
const target = agentDirOrExit(dir);
|
|
35
|
+
const environment = options.environment ?? "dev";
|
|
36
|
+
if (environment !== "dev" && environment !== "production")
|
|
37
|
+
return failUsage("--env must be dev or production");
|
|
38
|
+
selectAgentEnvironment(environment);
|
|
39
|
+
enterAgentEnv(target);
|
|
40
|
+
if (action === "list") {
|
|
41
|
+
const servers = await inspectMcp(target).catch(failStartup);
|
|
42
|
+
if (options.json)
|
|
43
|
+
console.log(JSON.stringify(servers));
|
|
44
|
+
else
|
|
45
|
+
for (const item of servers)
|
|
46
|
+
console.log(`${item.name}\t${item.transport}\t${item.authorization}`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!server)
|
|
50
|
+
return failUsage(`mcp ${action} needs a server name from .mcp.json`);
|
|
51
|
+
const context = mcpContext(target);
|
|
52
|
+
if (action === "disconnect") {
|
|
53
|
+
await holdingInstance(context.stateRoot, () => writeMcpBinding(context.stateRoot, server, undefined)).catch(failStartup);
|
|
54
|
+
printReport(!!options.json, {
|
|
55
|
+
status: "done",
|
|
56
|
+
server,
|
|
57
|
+
grant_revoked: false,
|
|
58
|
+
agent_next_steps: "The instance binding was removed. The stored grant was not revoked; reauthorize or update .mcp.json before reopening.",
|
|
59
|
+
}, ["Binding removed; the stored grant was not revoked. Reauthorize or update .mcp.json before reopening."]);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
httpServer(resolveMcpServers(target, context.environment, server), server);
|
|
63
|
+
const seconds = options.timeout === undefined ? 900 : Number(options.timeout);
|
|
64
|
+
if (!Number.isInteger(seconds) || seconds <= 0)
|
|
65
|
+
return failUsage("--timeout must be a positive whole number of seconds");
|
|
66
|
+
const scopes = options.scopes
|
|
67
|
+
?.split(",")
|
|
68
|
+
.map((value) => value.trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
const unattended = options.json || options.input === false || !isInteractive();
|
|
71
|
+
if (unattended && scopes === undefined)
|
|
72
|
+
return failUsage("mcp login without a terminal needs --scopes <comma-separated-scopes>; consult the MCP service's authorization requirements");
|
|
73
|
+
const run = async (step) => {
|
|
74
|
+
const grant = await signInMcp({
|
|
75
|
+
agentDir: target,
|
|
76
|
+
server,
|
|
77
|
+
store: context.auth.store,
|
|
78
|
+
scopes,
|
|
79
|
+
clientId: options.clientId,
|
|
80
|
+
callbackPort: options.callbackPort === undefined ? undefined : Number(options.callbackPort),
|
|
81
|
+
timeoutSeconds: seconds,
|
|
82
|
+
interaction: {
|
|
83
|
+
signal: step?.signal,
|
|
84
|
+
async confirmAuthorization(request) {
|
|
85
|
+
const message = `Authorize ${request.server} at ${request.issuer} for ${request.scopes.join(", ") || "server-defined scopes"}?`;
|
|
86
|
+
if (step) {
|
|
87
|
+
console.error(`[fastagent] ${message}`);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
const choice = await confirm({ message, initialValue: false });
|
|
91
|
+
return !isCancel(choice) && choice;
|
|
92
|
+
},
|
|
93
|
+
notify(event) {
|
|
94
|
+
if (event.type === "auth_url") {
|
|
95
|
+
const instructions = `Authorize the MCP server ${server} in your browser; scope: ${scopes?.join(", ") ?? "as approved"}. The callback is on the machine running this command; do not paste codes into chat.`;
|
|
96
|
+
if (step)
|
|
97
|
+
step.person.waitsOn(event.url, instructions);
|
|
98
|
+
else {
|
|
99
|
+
console.error(`${instructions}\n${event.url}`);
|
|
100
|
+
openExternalUrl(event.url);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
step?.person.carriesOn();
|
|
105
|
+
console.error(`[fastagent] ${event.message}`);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
await writeMcpBinding(context.stateRoot, server, grant.id);
|
|
111
|
+
return { server, grant_id: grant.id, scopes: grant.scopes };
|
|
112
|
+
};
|
|
113
|
+
if (options.ticket) {
|
|
114
|
+
await runTicket(options.ticket, (step) => run(step));
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
if (unattended && scopes !== undefined) {
|
|
118
|
+
const args = [
|
|
119
|
+
process.argv[1],
|
|
120
|
+
"mcp",
|
|
121
|
+
"login",
|
|
122
|
+
server,
|
|
123
|
+
target,
|
|
124
|
+
"--env",
|
|
125
|
+
environment,
|
|
126
|
+
"--scopes",
|
|
127
|
+
scopes.join(","),
|
|
128
|
+
"--timeout",
|
|
129
|
+
String(seconds),
|
|
130
|
+
];
|
|
131
|
+
if (options.clientId)
|
|
132
|
+
args.push("--client-id", options.clientId);
|
|
133
|
+
if (options.callbackPort)
|
|
134
|
+
args.push("--callback-port", options.callbackPort);
|
|
135
|
+
const ticket = await startTicket({
|
|
136
|
+
subject: `mcp:login:${environment}:${target}:${server}`,
|
|
137
|
+
action: `authorize MCP server ${server}`,
|
|
138
|
+
check: CHECK,
|
|
139
|
+
restart: `fastagent ${args.slice(1).map(shellArg).join(" ")}`,
|
|
140
|
+
timeoutSeconds: seconds,
|
|
141
|
+
whenRunning: "replace",
|
|
142
|
+
waitsFirst: false,
|
|
143
|
+
command: { file: process.execPath, args: [...process.execArgv, ...args], cwd: process.cwd() },
|
|
144
|
+
});
|
|
145
|
+
report(ticket, !!options.json, "started");
|
|
146
|
+
process.exit(ticketExitCode(ticket));
|
|
147
|
+
}
|
|
148
|
+
const result = await run().catch((error) => {
|
|
149
|
+
if (error instanceof McpError && error.code === "canceled")
|
|
150
|
+
return failUsage(error.message);
|
|
151
|
+
return failStartup(error);
|
|
152
|
+
});
|
|
153
|
+
printReport(!!options.json, { status: "done", ...result }, [
|
|
154
|
+
"MCP authorization saved and bound; reopen the agent to use it",
|
|
155
|
+
]);
|
|
156
|
+
}
|
package/dist/cli/program.js
CHANGED
|
@@ -703,6 +703,64 @@ const auth = {
|
|
|
703
703
|
},
|
|
704
704
|
],
|
|
705
705
|
};
|
|
706
|
+
const mcp = {
|
|
707
|
+
name: "mcp",
|
|
708
|
+
summary: "inspect MCP servers and manage explicit OAuth bindings",
|
|
709
|
+
subcommands: [
|
|
710
|
+
{
|
|
711
|
+
name: "list",
|
|
712
|
+
summary: "inspect declarations and local auth",
|
|
713
|
+
description: "Read .mcp.json and local grant metadata without contacting servers.",
|
|
714
|
+
examples: [{ cmd: "fastagent mcp list --json" }],
|
|
715
|
+
args: [AGENT_ARG],
|
|
716
|
+
flags: [CHANNEL_ENVIRONMENT, JSON_FLAG],
|
|
717
|
+
run: async (args, f) => (await import("./commands/mcp.js")).runMcp("list", undefined, args[0], {
|
|
718
|
+
json: f.json === true,
|
|
719
|
+
environment: f.env,
|
|
720
|
+
}),
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
name: "login",
|
|
724
|
+
summary: "sign in to an MCP server",
|
|
725
|
+
description: "Authorize in the browser and save an explicit grant binding for this instance.",
|
|
726
|
+
examples: [
|
|
727
|
+
{ cmd: "fastagent mcp login linear --scopes read --json" },
|
|
728
|
+
{ cmd: "fastagent mcp login --check <ticket> --json" },
|
|
729
|
+
],
|
|
730
|
+
args: [{ name: "[server]", description: "the server name in .mcp.json" }, AGENT_ARG],
|
|
731
|
+
flags: [
|
|
732
|
+
CHANNEL_ENVIRONMENT,
|
|
733
|
+
...UNATTENDED_SETUP,
|
|
734
|
+
{ flags: "--scopes <scopes>", description: "comma-separated OAuth scopes; required without a terminal" },
|
|
735
|
+
{ flags: "--client-id <id>", description: "a pre-registered public client ID or client metadata URL" },
|
|
736
|
+
{ flags: "--callback-port <port>", description: "local callback port (default: allocate an available port)" },
|
|
737
|
+
],
|
|
738
|
+
run: async (args, f) => (await import("./commands/mcp.js")).runMcp("login", args[0], args[1], {
|
|
739
|
+
json: f.json === true,
|
|
740
|
+
input: f.input !== false,
|
|
741
|
+
environment: f.env,
|
|
742
|
+
scopes: f.scopes,
|
|
743
|
+
clientId: f.clientId,
|
|
744
|
+
callbackPort: f.callbackPort,
|
|
745
|
+
check: f.check,
|
|
746
|
+
ticket: f.ticket,
|
|
747
|
+
timeout: f.timeout,
|
|
748
|
+
}),
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
name: "disconnect",
|
|
752
|
+
summary: "remove a local OAuth binding",
|
|
753
|
+
description: "Remove this instance's binding without revoking or deleting a shared grant. Reopen running instances afterward.",
|
|
754
|
+
examples: [{ cmd: "fastagent mcp disconnect linear" }],
|
|
755
|
+
args: [{ name: "<server>", description: "the server name" }, AGENT_ARG],
|
|
756
|
+
flags: [CHANNEL_ENVIRONMENT, JSON_FLAG],
|
|
757
|
+
run: async (args, f) => (await import("./commands/mcp.js")).runMcp("disconnect", args[0], args[1], {
|
|
758
|
+
json: f.json === true,
|
|
759
|
+
environment: f.env,
|
|
760
|
+
}),
|
|
761
|
+
},
|
|
762
|
+
],
|
|
763
|
+
};
|
|
706
764
|
/**
|
|
707
765
|
* Registration order = help order — the ORIGINAL usage wall's order, kept verbatim (this is a commander refactor of
|
|
708
766
|
* the same CLI, not a redesign).
|
|
@@ -710,6 +768,7 @@ const auth = {
|
|
|
710
768
|
export const specs = [
|
|
711
769
|
init,
|
|
712
770
|
models,
|
|
771
|
+
mcp,
|
|
713
772
|
info,
|
|
714
773
|
content,
|
|
715
774
|
env,
|
|
@@ -142,7 +142,7 @@ export async function destroyAgentcoreDeployment(plan, runner, announce = () =>
|
|
|
142
142
|
// FAILURE vs WARNING is decided by whether the resource SURVIVES it, not by whether a call failed. The
|
|
143
143
|
// forwarder's group is named exactly, so a `--run` deletes it by name and the failed listing changed nothing
|
|
144
144
|
// — reporting `1 resource(s) survived` for a teardown that removed everything is the same false signal as the
|
|
145
|
-
// reverse
|
|
145
|
+
// reverse. In the inventory, where no delete follows, the same
|
|
146
146
|
// unreadable listing DOES mean the answer is incomplete.
|
|
147
147
|
const failures = [];
|
|
148
148
|
const warnings = [];
|
|
@@ -3,6 +3,7 @@ import { isAbsolute, relative, join, sep } from "node:path";
|
|
|
3
3
|
import ignore from "ignore";
|
|
4
4
|
import { AGENT_CONFIG_FILE, CONTENT_DIRNAME, LOCAL_CONTEXT_FILE, SECRETS_DIRNAME, exists, readTextIfExists, resolveSecretsDir, resolveStateRoot, } from "../paths.js";
|
|
5
5
|
import { isGeneratedDockerignore } from "./container.js";
|
|
6
|
+
import { MCP_FILE } from "../mcp/config.js";
|
|
6
7
|
/**
|
|
7
8
|
* "Would docker's packer drop this path?" — built from a `.dockerignore`'s text via the `ignore` matcher (the same
|
|
8
9
|
* library the scaffolded ignore files use), so `!` negation and last-match-wins are the library's problem, not ours.
|
|
@@ -120,6 +121,9 @@ export async function checkKeptIgnoreFiles(ctx, report) {
|
|
|
120
121
|
`config (the deployed box has no agent to open and crash-loops). Remove that rule before deploying.`;
|
|
121
122
|
report.issue(text);
|
|
122
123
|
}
|
|
124
|
+
if (excluded(MCP_FILE) && (await exists(join(agentDir, MCP_FILE)))) {
|
|
125
|
+
report.issue(`your ${rel} excludes ${MCP_FILE}; the deployment would lose its MCP servers. Remove that rule before deploying.`);
|
|
126
|
+
}
|
|
123
127
|
// Resolved paths, not spellings: dockerignore patterns are root-anchored (unlike .gitignore).
|
|
124
128
|
const leaks = leakCandidates.filter((p) => !excluded(p));
|
|
125
129
|
if (leaks.length > 0) {
|
package/dist/deploy/container.js
CHANGED
|
@@ -149,8 +149,7 @@ ${deployment}`;
|
|
|
149
149
|
//
|
|
150
150
|
// `*.tgz` RIDES ALONG with the manifest, because the install layer runs BEFORE `COPY . .` and a
|
|
151
151
|
// `"dep": "file:./something.tgz"` resolves against the directory it is installing in. Without this the file is
|
|
152
|
-
// not there yet and the build fails on a dependency the author can see sitting in the agent directory.
|
|
153
|
-
// also what lets a live probe deploy the CHECKOUT (`npm pack` output) rather than the published release.
|
|
152
|
+
// not there yet and the build fails on a dependency the author can see sitting in the agent directory.
|
|
154
153
|
if (isBun) {
|
|
155
154
|
// `--frozen-lockfile` needs bun.lock and hard-fails without it.
|
|
156
155
|
const install = input.hasLockfile ? "bun install --frozen-lockfile" : "bun install";
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
/** `fastagent deploy docker --run` — reconcile the generated/user-owned Compose application locally. */
|
|
2
1
|
import type { BoxLoginStep } from "../box-shell.ts";
|
|
3
2
|
import type { DeclaredChannel } from "../../channels/discover.ts";
|
|
4
3
|
import type { RegistrationOutcome } from "../../channels/registration.ts";
|
|
5
|
-
import type
|
|
4
|
+
import { type CliRunner } from "../runner.ts";
|
|
6
5
|
export interface DockerRunPlan {
|
|
7
6
|
/** Compose file relative to the runner cwd (the agent directory). */
|
|
8
7
|
composeFile: string;
|
|
@@ -54,6 +53,11 @@ type DockerAnnounce = (baseUrl: string) => Promise<{
|
|
|
54
53
|
* budget.
|
|
55
54
|
*/
|
|
56
55
|
export type DockerHealthProbe = (healthUrl: string, stillStarting: () => Promise<boolean>) => Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* Whether something already accepts connections at `host:port`. The answer to "is the port the agent is about to
|
|
58
|
+
* publish free", asked before `up`, because afterwards a `/health` that answers cannot say whose it is.
|
|
59
|
+
*/
|
|
60
|
+
export type HostPortProbe = (host: string, port: number) => Promise<boolean>;
|
|
57
61
|
/** A published Quick Tunnel URL, and whether its tunnel ever reported an edge connection. */
|
|
58
62
|
export interface ComposeTunnel {
|
|
59
63
|
url: string;
|
|
@@ -62,6 +66,15 @@ export interface ComposeTunnel {
|
|
|
62
66
|
export type DockerTunnelUrlProbe = (docker: CliRunner, composeFile: string) => Promise<ComposeTunnel | undefined>;
|
|
63
67
|
/** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
|
|
64
68
|
export declare function localUrlFromComposePort(stdout: string): string | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* The host ports the `agent` service publishes, from `docker compose config --format json` (Compose has already
|
|
71
|
+
* normalised every short or long syntax into `ports[]`). A port with no fixed host side (`published` unset: Docker picks
|
|
72
|
+
* one) cannot collide and is left out. A wildcard bind is asked on loopback, which is where the URL we report points.
|
|
73
|
+
*/
|
|
74
|
+
export declare function agentPublishedPorts(stdout: string): {
|
|
75
|
+
host: string;
|
|
76
|
+
port: number;
|
|
77
|
+
}[];
|
|
65
78
|
/**
|
|
66
79
|
* Poll the detached cloudflared service's logs until its Quick Tunnel URL is assigned AND the tunnel reports an edge
|
|
67
80
|
* connection.
|
|
@@ -71,5 +84,5 @@ export declare function waitForComposeTunnelUrl(docker: CliRunner, composeFile:
|
|
|
71
84
|
intervalMs?: number;
|
|
72
85
|
sleep?: (ms: number) => Promise<void>;
|
|
73
86
|
}): Promise<ComposeTunnel | undefined>;
|
|
74
|
-
export declare function deployDockerRun(plan: DockerRunPlan, docker: CliRunner, log: (message: string) => void, healthProbe?: DockerHealthProbe, tunnelUrlProbe?: DockerTunnelUrlProbe): Promise<DockerRunOutcome>;
|
|
87
|
+
export declare function deployDockerRun(plan: DockerRunPlan, docker: CliRunner, log: (message: string) => void, healthProbe?: DockerHealthProbe, tunnelUrlProbe?: DockerTunnelUrlProbe, hostPortBusy?: HostPortProbe): Promise<DockerRunOutcome>;
|
|
75
88
|
export {};
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
/** `fastagent deploy docker --run` — reconcile the generated/user-owned Compose application locally. */
|
|
2
|
+
import { connect } from "node:net";
|
|
1
3
|
import { waitForHealth } from "../../channels/wait-health.js";
|
|
2
4
|
import { loginGate } from "../channel-ingress.js";
|
|
3
5
|
import { TUNNEL_DNS_LAG_MS, hasTunnelConnection, parseTunnelUrl } from "../../tunnel.js";
|
|
4
6
|
import { registrationGate } from "../registration-gate.js";
|
|
5
7
|
import { MIN_DOCKER_COMPOSE_VERSION } from "./plan.js";
|
|
8
|
+
import { readOutput } from "../runner.js";
|
|
6
9
|
import { missingValuesGate } from "../secrets.js";
|
|
7
10
|
/** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
|
|
8
11
|
export function localUrlFromComposePort(stdout) {
|
|
@@ -18,6 +21,36 @@ export function localUrlFromComposePort(stdout) {
|
|
|
18
21
|
* so this budget covers a copy on a slow Docker Desktop disk, not a listen.
|
|
19
22
|
*/
|
|
20
23
|
const defaultHealthProbe = (healthUrl, stillStarting) => waitForHealth(healthUrl, 180_000, 500, stillStarting);
|
|
24
|
+
const defaultHostPortBusy = (host, port) => new Promise((resolve) => {
|
|
25
|
+
const socket = connect({ host, port });
|
|
26
|
+
const answer = (busy) => {
|
|
27
|
+
socket.destroy();
|
|
28
|
+
resolve(busy);
|
|
29
|
+
};
|
|
30
|
+
socket.setTimeout(1_000, () => answer(false)); // a filtered port is not one something is serving
|
|
31
|
+
socket.once("connect", () => answer(true));
|
|
32
|
+
socket.once("error", () => answer(false));
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* The host ports the `agent` service publishes, from `docker compose config --format json` (Compose has already
|
|
36
|
+
* normalised every short or long syntax into `ports[]`). A port with no fixed host side (`published` unset: Docker picks
|
|
37
|
+
* one) cannot collide and is left out. A wildcard bind is asked on loopback, which is where the URL we report points.
|
|
38
|
+
*/
|
|
39
|
+
export function agentPublishedPorts(stdout) {
|
|
40
|
+
const config = JSON.parse(stdout);
|
|
41
|
+
const ports = config?.services?.agent?.ports;
|
|
42
|
+
if (ports === undefined)
|
|
43
|
+
return [];
|
|
44
|
+
if (!Array.isArray(ports))
|
|
45
|
+
throw new Error("services.agent.ports is not a list");
|
|
46
|
+
return ports.flatMap((entry) => {
|
|
47
|
+
const port = Number(entry?.published);
|
|
48
|
+
if (!Number.isInteger(port) || port <= 0 || (entry.protocol ?? "tcp") !== "tcp")
|
|
49
|
+
return [];
|
|
50
|
+
const wildcard = !entry.host_ip || entry.host_ip === "0.0.0.0" || entry.host_ip === "::";
|
|
51
|
+
return [{ host: wildcard ? "127.0.0.1" : entry.host_ip, port }];
|
|
52
|
+
});
|
|
53
|
+
}
|
|
21
54
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
55
|
/**
|
|
23
56
|
* Poll the detached cloudflared service's logs until its Quick Tunnel URL is assigned AND the tunnel reports an edge
|
|
@@ -59,7 +92,7 @@ async function containerHealthy(docker, compose, port, stillStarting) {
|
|
|
59
92
|
}
|
|
60
93
|
return false;
|
|
61
94
|
}
|
|
62
|
-
export async function deployDockerRun(plan, docker, log, healthProbe = defaultHealthProbe, tunnelUrlProbe = defaultTunnelUrlProbe) {
|
|
95
|
+
export async function deployDockerRun(plan, docker, log, healthProbe = defaultHealthProbe, tunnelUrlProbe = defaultTunnelUrlProbe, hostPortBusy = defaultHostPortBusy) {
|
|
63
96
|
const gate = (message) => ({ ok: false, gate: message });
|
|
64
97
|
const compose = ["compose", "-f", plan.composeFile];
|
|
65
98
|
// CLI/plugin gate first: unlike a daemon error, spawn ENOENT becomes 127 at the shared runner seam.
|
|
@@ -99,6 +132,26 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
|
|
|
99
132
|
return gate(`--tunnel was requested but the kept ${plan.composeFile} has no "tunnel" service — ` +
|
|
100
133
|
`edit it, delete it and regenerate, or pass --force`);
|
|
101
134
|
}
|
|
135
|
+
// A port something else already serves would answer the `/health` this run waits for, and the run would report that
|
|
136
|
+
// process's answer as the agent's. A container this project already runs holds its own port, so only a first start
|
|
137
|
+
// (or a start after `down`) asks. Before the first side effect: a refusal leaves nothing to undo.
|
|
138
|
+
const hostPorts = await readOutput(docker, "docker", [...compose, "config", "--format", "json"], agentPublishedPorts);
|
|
139
|
+
if ("gate" in hostPorts)
|
|
140
|
+
return gate(hostPorts.gate);
|
|
141
|
+
if (hostPorts.value.length > 0) {
|
|
142
|
+
const running = await docker([...compose, "ps", "--status", "running", "--services"], { capture: true });
|
|
143
|
+
const ours = running.code === 0 && running.stdout.split(/\s+/).includes("agent");
|
|
144
|
+
if (!ours) {
|
|
145
|
+
for (const { host, port } of hostPorts.value) {
|
|
146
|
+
if (await hostPortBusy(host, port)) {
|
|
147
|
+
return gate(`${host}:${port} is already in use by another process, so the agent's published port is not free and a ` +
|
|
148
|
+
`health check there would answer for that process, not for the agent — stop it, or move the agent: ` +
|
|
149
|
+
`edit the published port in ${plan.composeFile}, or change http.port in fastagent.config.ts and re-run ` +
|
|
150
|
+
`with --force (a generated Compose file is kept otherwise)`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
102
155
|
// Quick Tunnel logs are the control-plane output (the assigned URL).
|
|
103
156
|
if (hasTunnel) {
|
|
104
157
|
log("recreating the ephemeral tunnel service…");
|