@fastagent-sh/fastagent 0.27.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 (75) hide show
  1. package/README.md +4 -3
  2. package/dist/authoring.d.ts +14 -7
  3. package/dist/authoring.js +11 -7
  4. package/dist/channels/feishu/feishu-api.d.ts +1 -0
  5. package/dist/channels/feishu/feishu-api.js +4 -0
  6. package/dist/channels/feishu/register-app.d.ts +2 -0
  7. package/dist/channels/feishu/register-app.js +1 -1
  8. package/dist/channels/lark/credentials-page.d.ts +25 -0
  9. package/dist/channels/lark/credentials-page.js +206 -0
  10. package/dist/channels/lark/onboard.d.ts +18 -2
  11. package/dist/channels/lark/onboard.js +36 -6
  12. package/dist/channels/slack/setup-server.js +75 -30
  13. package/dist/channels/wait-health.js +1 -1
  14. package/dist/cli/add-feishu.d.ts +0 -9
  15. package/dist/cli/add-feishu.js +62 -38
  16. package/dist/cli/commands/add.js +5 -12
  17. package/dist/cli/commands/content.js +1 -1
  18. package/dist/cli/commands/deploy.js +1 -8
  19. package/dist/cli/commands/info.js +4 -0
  20. package/dist/cli/commands/invoke.js +5 -3
  21. package/dist/cli/commands/mcp.d.ts +12 -0
  22. package/dist/cli/commands/mcp.js +156 -0
  23. package/dist/cli/content-view.d.ts +1 -1
  24. package/dist/cli/content-view.js +2 -2
  25. package/dist/cli/program.js +65 -9
  26. package/dist/content/declare.d.ts +1 -6
  27. package/dist/content/declare.js +13 -18
  28. package/dist/content/resolve.d.ts +0 -2
  29. package/dist/content/resolve.js +2 -4
  30. package/dist/content/source.d.ts +0 -1
  31. package/dist/content/source.js +0 -1
  32. package/dist/deploy/agentcore/destroy.js +1 -1
  33. package/dist/deploy/build-context.js +4 -0
  34. package/dist/deploy/container.js +1 -2
  35. package/dist/deploy/docker/run.d.ts +16 -3
  36. package/dist/deploy/docker/run.js +54 -1
  37. package/dist/deploy/fly/run.d.ts +0 -14
  38. package/dist/deploy/fly/run.js +4 -5
  39. package/dist/deploy/preflight.js +6 -14
  40. package/dist/dev-supervisor.js +3 -2
  41. package/dist/harnesses/pi-durable/mcp.d.ts +3 -0
  42. package/dist/harnesses/pi-durable/mcp.js +34 -0
  43. package/dist/harnesses/pi-durable/open.d.ts +3 -4
  44. package/dist/harnesses/pi-durable/open.js +13 -10
  45. package/dist/harnesses/pi-durable/serve.d.ts +5 -1
  46. package/dist/harnesses/pi-durable/serve.js +47 -17
  47. package/dist/harnesses/pi-durable/text.js +7 -13
  48. package/dist/http/contract.d.ts +3 -3
  49. package/dist/index.d.ts +9 -1
  50. package/dist/index.js +6 -1
  51. package/dist/locked-file.d.ts +2 -2
  52. package/dist/locked-file.js +8 -3
  53. package/dist/mcp/auth.d.ts +22 -0
  54. package/dist/mcp/auth.js +180 -0
  55. package/dist/mcp/client.d.ts +15 -0
  56. package/dist/mcp/client.js +170 -0
  57. package/dist/mcp/config.d.ts +34 -0
  58. package/dist/mcp/config.js +142 -0
  59. package/dist/mcp/grants.d.ts +4 -0
  60. package/dist/mcp/grants.js +132 -0
  61. package/dist/mcp/host.d.ts +16 -0
  62. package/dist/mcp/host.js +120 -0
  63. package/dist/mcp/network.d.ts +12 -0
  64. package/dist/mcp/network.js +150 -0
  65. package/dist/mcp/sign-in.d.ts +2 -0
  66. package/dist/mcp/sign-in.js +204 -0
  67. package/dist/mcp-api.d.ts +93 -0
  68. package/dist/mcp-api.js +10 -0
  69. package/dist/model-ref.d.ts +3 -0
  70. package/dist/model-ref.js +7 -0
  71. package/dist/setup-page.d.ts +51 -0
  72. package/dist/setup-page.js +149 -0
  73. package/dist/value-file.d.ts +1 -0
  74. package/dist/value-file.js +15 -16
  75. package/package.json +3 -2
@@ -1,21 +1,41 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
- const escapeHtml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
4
- const page = (body, head = "") => `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width">${head}` +
5
- `<title>FastAgent Slack setup</title><body style="font:16px system-ui;max-width:40em;margin:3em auto;padding:0 1em">${body}`;
3
+ import { field, formCard, openButton, privacy, prose, refusal, sendPage, setupRequestUrl, statusCard, step, submit, } from "../../setup-page.js";
4
+ /** Where Slack's App Configuration tokens are generated: the page names the section, and links to the page it is on. */
5
+ const SLACK_APPS_URL = "https://api.slack.com/apps";
6
+ const TITLE = "Connect Slack — FastAgent";
6
7
  function tokensForm(error) {
7
- return page(`<h1>Slack App Configuration token</h1>` +
8
- `<ol><li>Open <a href="https://api.slack.com/apps" target=_blank rel=noopener>api.slack.com/apps</a>, then ` +
9
- `<b>Your App Configuration Tokens</b> → <b>Generate Token</b> for the workspace the agent joins.</li>` +
10
- `<li>Paste both tokens here.</li></ol>` +
11
- `<p>FastAgent keeps them on this machine only (owner-readable), uses them to create the app and update its ` +
12
- `Request URL, and never deploys them.</p>` +
13
- (error ? `<p style="color:#b00"><b>${escapeHtml(error)}</b></p>` : "") +
14
- `<form method=post><p><label>Access token<br><input name=access type=password required ` +
15
- `placeholder="xoxe.xoxp-…" style="width:100%"></label></p><p><label>Refresh token<br><input name=refresh ` +
16
- `type=password required placeholder="xoxe-…" style="width:100%"></label></p>` +
17
- `<p><button>Create the Slack app</button></p></form>`);
8
+ return {
9
+ title: TITLE,
10
+ card: formCard("Connect Slack", [
11
+ step(1, "Generate two tokens in Slack", prose("Under <b>Your App Configuration Tokens</b>, choose <b>Generate Token</b> for your workspace.") +
12
+ openButton("Open Slack", SLACK_APPS_URL)),
13
+ step(2, "Paste them here", (error ? refusal("Those tokens were not accepted.", ` ${error}`) : "") +
14
+ field({
15
+ id: "access",
16
+ label: "Access Token",
17
+ placeholder: "xoxe.xoxp-…",
18
+ secret: true,
19
+ invalid: error !== undefined,
20
+ autofocus: error !== undefined,
21
+ }) +
22
+ field({
23
+ id: "refresh",
24
+ label: "Refresh Token",
25
+ placeholder: "xoxe-…",
26
+ secret: true,
27
+ invalid: error !== undefined,
28
+ }) +
29
+ submit("Create Slack app") +
30
+ privacy(), { bare: error === undefined }),
31
+ ].join("")),
32
+ };
18
33
  }
34
+ /** A page that says where the setup stands. */
35
+ const status = (input, title = "FastAgent setup") => ({
36
+ title,
37
+ card: statusCard(input),
38
+ });
19
39
  /** Temporary onboarding-only responder. */
20
40
  export async function startSlackSetupServer() {
21
41
  const nonce = randomBytes(24).toString("hex");
@@ -45,39 +65,53 @@ export async function startSlackSetupServer() {
45
65
  settleOAuth = resolve;
46
66
  });
47
67
  const server = createServer((request, response) => {
48
- const url = new URL(request.url ?? "/", "http://127.0.0.1");
68
+ const url = setupRequestUrl(request, response);
69
+ if (url === undefined)
70
+ return;
49
71
  if (request.method === "GET" && url.pathname === "/health") {
50
72
  response.end("ok\n");
51
73
  return;
52
74
  }
53
75
  if (request.method === "GET" && url.pathname === redirectPath) {
54
- settleOAuth?.({
76
+ const callback = {
55
77
  code: url.searchParams.get("code") ?? undefined,
56
78
  state: url.searchParams.get("state") ?? undefined,
57
79
  error: url.searchParams.get("error") ?? undefined,
58
- });
80
+ };
81
+ settleOAuth?.(callback);
59
82
  settleOAuth = undefined;
60
- response.statusCode = 200;
61
- response.setHeader("content-type", "text/html; charset=utf-8");
62
- response.end(page("<p>Slack authorization received: FastAgent is finishing the install. You can close this page.</p>"));
83
+ // The onboarding flow still has to validate state and exchange the code.
84
+ sendPage(response, 200, status(callback.error !== undefined
85
+ ? {
86
+ state: "failed",
87
+ heading: "Slack authorization was not approved",
88
+ lede: callback.error
89
+ ? `OAuth error: ${callback.error}`
90
+ : "The callback reported an authorization error.",
91
+ next: "Go back to your coding agent to restart setup.",
92
+ }
93
+ : {
94
+ state: "working",
95
+ heading: "OAuth response received",
96
+ lede: "FastAgent still needs to verify this response and finish the installation.",
97
+ next: "Go back to your coding agent to check the result. You can close this tab.",
98
+ }));
63
99
  return;
64
100
  }
65
101
  if (url.pathname === tokensPath && (request.method === "GET" || request.method === "POST")) {
66
- response.setHeader("content-type", "text/html; charset=utf-8");
67
102
  if (takeTokens === undefined) {
68
103
  // Taken already, or not asked for: the page is one-time.
69
- response.statusCode = 404;
70
- response.end(page("<p>This page is no longer in use.</p>"));
104
+ sendPage(response, 404, status({ state: "failed", heading: "This page is no longer in use", next: "Go back to your coding agent." }));
71
105
  return;
72
106
  }
73
107
  if (request.method === "GET") {
74
- response.end(tokensForm());
108
+ sendPage(response, 200, tokensForm());
75
109
  return;
76
110
  }
77
111
  readBody(request, (body) => {
78
112
  if (body === undefined) {
79
113
  response.statusCode = 413;
80
- response.end(page("<p>Too large.</p>"));
114
+ response.end("payload too large\n");
81
115
  return;
82
116
  }
83
117
  const form = new URLSearchParams(body);
@@ -85,7 +119,7 @@ export async function startSlackSetupServer() {
85
119
  takeTokens?.({ access: (form.get("access") ?? "").trim(), refresh: (form.get("refresh") ?? "").trim() });
86
120
  }
87
121
  catch (error) {
88
- response.end(tokensForm(error.message));
122
+ sendPage(response, 200, tokensForm(error.message));
89
123
  return;
90
124
  }
91
125
  response.statusCode = 303;
@@ -95,9 +129,16 @@ export async function startSlackSetupServer() {
95
129
  return;
96
130
  }
97
131
  if (request.method === "GET" && url.pathname === nextPath) {
98
- response.setHeader("content-type", "text/html; charset=utf-8");
99
132
  if (after === undefined) {
100
- response.end(page("<p>Creating the Slack app…</p>", '<meta http-equiv="refresh" content="2">'));
133
+ // Says what comes next, so the redirect to Slack's page is no surprise.
134
+ sendPage(response, 200, {
135
+ ...status({
136
+ state: "working",
137
+ heading: "Creating your Slack app",
138
+ lede: "In a moment Slack will ask you to click Allow.",
139
+ }),
140
+ head: '<meta http-equiv="refresh" content="2">',
141
+ });
101
142
  }
102
143
  else if ("url" in after) {
103
144
  response.statusCode = 303;
@@ -105,8 +146,12 @@ export async function startSlackSetupServer() {
105
146
  response.end();
106
147
  }
107
148
  else {
108
- const style = after.failed ? ' style="color:#b00"' : "";
109
- response.end(page(`<p${style}><b>${escapeHtml(after.said)}</b></p><p>Return to your agent.</p>`));
149
+ sendPage(response, 200, status({
150
+ state: after.failed ? "failed" : "done",
151
+ heading: after.failed ? "Setup stopped" : "Done",
152
+ lede: after.said,
153
+ next: "Go back to your coding agent.",
154
+ }));
110
155
  lastShown?.();
111
156
  }
112
157
  return;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Readiness probe for a server THIS process can reach over HTTP — the local port `deploy docker --run` just
3
- * published, the live probes' own origins, and the public URL a host deploy must clear before it registers a webhook
3
+ * published, and the public URL a host deploy must clear before it registers a webhook
4
4
  * (`publicHealthGate` in deploy/channel-ingress.ts).
5
5
  */
6
6
  import { setTimeout as sleep } from "node:timers/promises";
@@ -30,15 +30,6 @@ export declare function onboardFeishuCloudApp(target: string, kind: "feishu" | "
30
30
  rerun?: string,
31
31
  /** Who takes the steps only a person can: at this terminal, or a coding agent's person through a ticket. */
32
32
  person?: Person): Promise<Record<string, string> | undefined>;
33
- /**
34
- * Lark's app credentials, which only the person can get, from the app they create in Lark's console: with no one at a
35
- * terminal to type them, the report naming where they set them (the selected environment's file), when either is
36
- * absent. `again` is the command that continues once they have.
37
- */
38
- export declare function larkCredentialsNeeded(agentDir: string, again: string): Promise<{
39
- json: Record<string, unknown>;
40
- text: string[];
41
- } | undefined>;
42
33
  export declare function feishuAppSecretNames(kind: "feishu" | "lark", ingress: FeishuSubscriptionMode): string[];
43
34
  interface FeishuAppSetup {
44
35
  kind: "feishu" | "lark";
@@ -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`);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `fastagent content list|add|remove`: what the agent works on and knows, as `context.json` declares it and
2
+ * `fastagent content list|add|remove`: what the agent works on, as `context.json` declares it and
3
3
  * `context.local.json` binds it on this machine.
4
4
  */
5
5
  import { resolve } from "node:path";
@@ -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)");
@@ -8,14 +8,12 @@ import { AgentError } from "../../agent-api.js";
8
8
  import { enterAgentEnv } from "../../env.js";
9
9
  import { dotEnvPath, loadEnvValues } from "../../value-file.js";
10
10
  import { installProxyFetch } from "../../proxy.js";
11
- import { modelRefOf } from "../../harnesses/pi-durable/open.js";
12
- import { openServing } from "../../harnesses/pi-durable/serve.js";
11
+ import { modelRefOf } from "../../model-ref.js";
13
12
  import { connectAgent } from "../../http/client.js";
14
13
  import { API_KEY_VARIABLE, apiKeysFrom } from "../../http/server.js";
15
14
  import { resolveStateRoot, selectAgentEnvironment } from "../../paths.js";
16
15
  import { runInvokeJson, runInvokeStream } from "../invoke-stream.js";
17
16
  import { failStartup, failUsage, optionalAgentDirOrExit } from "../fail.js";
18
- import { enterAgentCommand, reportAuth } from "../shared.js";
19
17
  /**
20
18
  * `invoke`'s sessions, apart from the instance's storage, so `invoke` runs beside a serve of this directory, which holds
21
19
  * that one; one `invoke` at a time holds this one.
@@ -112,6 +110,10 @@ export async function runInvoke(message, dirArg, opts) {
112
110
  return runRemoteInvoke(message, dirArg, { ...opts, url: opts.url });
113
111
  if (opts.key !== undefined)
114
112
  failUsage("--key is the key --url calls with: pass --url too");
113
+ const [{ openServing }, { enterAgentCommand, reportAuth }] = await Promise.all([
114
+ import("../../harnesses/pi-durable/serve.js"),
115
+ import("../shared.js"),
116
+ ]);
115
117
  const { agentDir, modelSpec } = await enterAgentCommand(dirArg, opts);
116
118
  const file = invokeStorageFile(resolveStateRoot(agentDir));
117
119
  const { agent, models, definition } = await openServing(agentDir, {
@@ -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
+ }
@@ -2,7 +2,7 @@
2
2
  import type { BoundContent } from "../content/declare.ts";
3
3
  import { type ResolvedContent } from "../content/resolve.ts";
4
4
  /**
5
- * The report lines for an agent's content — `works on` / `knows`, one per entry, then a `notice` line for each
5
+ * The report lines for an agent's content — `works on`, one per entry, then a `notice` line for each
6
6
  * thing its resolution has to say, then one for each directory this machine binds that is not here — shared by the
7
7
  * startup report, `info` and `content list`, so the three say the same thing.
8
8
  */