@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.
Files changed (59) hide show
  1. package/README.md +2 -1
  2. package/dist/channels/feishu/feishu-api.d.ts +1 -0
  3. package/dist/channels/feishu/feishu-api.js +4 -0
  4. package/dist/channels/feishu/register-app.d.ts +2 -0
  5. package/dist/channels/feishu/register-app.js +1 -1
  6. package/dist/channels/lark/credentials-page.d.ts +25 -0
  7. package/dist/channels/lark/credentials-page.js +206 -0
  8. package/dist/channels/lark/onboard.d.ts +18 -2
  9. package/dist/channels/lark/onboard.js +36 -6
  10. package/dist/channels/slack/setup-server.js +75 -30
  11. package/dist/channels/wait-health.js +1 -1
  12. package/dist/cli/add-feishu.d.ts +0 -9
  13. package/dist/cli/add-feishu.js +62 -38
  14. package/dist/cli/commands/add.js +5 -12
  15. package/dist/cli/commands/deploy.js +1 -8
  16. package/dist/cli/commands/info.js +4 -0
  17. package/dist/cli/commands/mcp.d.ts +12 -0
  18. package/dist/cli/commands/mcp.js +156 -0
  19. package/dist/cli/program.js +59 -0
  20. package/dist/deploy/agentcore/destroy.js +1 -1
  21. package/dist/deploy/build-context.js +4 -0
  22. package/dist/deploy/container.js +1 -2
  23. package/dist/deploy/docker/run.d.ts +16 -3
  24. package/dist/deploy/docker/run.js +54 -1
  25. package/dist/deploy/fly/run.d.ts +0 -14
  26. package/dist/deploy/fly/run.js +4 -5
  27. package/dist/dev-supervisor.js +3 -2
  28. package/dist/harnesses/pi-durable/mcp.d.ts +3 -0
  29. package/dist/harnesses/pi-durable/mcp.js +34 -0
  30. package/dist/harnesses/pi-durable/open.d.ts +1 -1
  31. package/dist/harnesses/pi-durable/open.js +12 -3
  32. package/dist/harnesses/pi-durable/serve.d.ts +4 -0
  33. package/dist/harnesses/pi-durable/serve.js +47 -17
  34. package/dist/http/contract.d.ts +3 -3
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.js +4 -0
  37. package/dist/locked-file.d.ts +2 -2
  38. package/dist/locked-file.js +8 -3
  39. package/dist/mcp/auth.d.ts +22 -0
  40. package/dist/mcp/auth.js +180 -0
  41. package/dist/mcp/client.d.ts +15 -0
  42. package/dist/mcp/client.js +170 -0
  43. package/dist/mcp/config.d.ts +34 -0
  44. package/dist/mcp/config.js +142 -0
  45. package/dist/mcp/grants.d.ts +4 -0
  46. package/dist/mcp/grants.js +132 -0
  47. package/dist/mcp/host.d.ts +16 -0
  48. package/dist/mcp/host.js +120 -0
  49. package/dist/mcp/network.d.ts +12 -0
  50. package/dist/mcp/network.js +150 -0
  51. package/dist/mcp/sign-in.d.ts +2 -0
  52. package/dist/mcp/sign-in.js +204 -0
  53. package/dist/mcp-api.d.ts +93 -0
  54. package/dist/mcp-api.js +10 -0
  55. package/dist/setup-page.d.ts +51 -0
  56. package/dist/setup-page.js +149 -0
  57. package/dist/value-file.d.ts +1 -0
  58. package/dist/value-file.js +15 -16
  59. package/package.json +3 -2
@@ -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 { LARK_CONSOLE_URL, onboardLarkApp } from "../channels/lark/onboard.js";
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, env, rerun), {
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
- * Lark's guided console through a coding agent's person: a page is a step left to them, with what they do there; a
157
- * value it would ask for is theirs to set in the value file, and the setup continues when run again.
158
- */
159
- function larkThrough(person, env, rerun) {
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(message) {
164
- throw new Error(`${message} is needed: ask the user to set it in ${env}, then run \`${rerun}\` again`);
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.
@@ -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, larkCredentialsNeeded, onboardFeishuCloudApp } from "../add-feishu.js";
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: what the person must provide first is
215
- * named (Lark's app credentials, in the file the agent reads), never asked for in the agent's conversation; then the
216
- * app's setup runs in a process of its own (ticket.ts), and the command returns once the person has a step to take, it
217
- * is done, or it is still working at it.
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, larkCredentialsNeeded, prepareFeishuApps, unpreparedFeishuApps, } from "../add-feishu.js";
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
+ }
@@ -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, and `test/live/env.ts` treats it as a leak. In the inventory, where no delete follows, the same
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) {
@@ -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. It is
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 { CliRunner } from "../runner.ts";
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…");