@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,5 +1,5 @@
1
1
  import type { ModelThinkingLevel, Models, Provider } from "@earendil-works/pi-ai";
2
- import { type Extension, type HarnessSettings, type ModelRef, type Storage, type ToolRegistration } from "@earendil-works/pi-durable";
2
+ import { type Extension, type HarnessSettings, type Storage, type ToolRegistration } from "@earendil-works/pi-durable";
3
3
  import type { ResolvedContent } from "../../content/resolve.ts";
4
4
  import { type DeclaredSecret } from "../../declared-secrets.ts";
5
5
  import type { ChannelModule, LongConnectionChannelModule } from "../../channel.ts";
@@ -14,14 +14,12 @@ export interface AgentDefinition {
14
14
  config: AgentConfig;
15
15
  registry: Registry;
16
16
  }
17
- export declare function modelRefOf(spec: string, where: string): ModelRef;
18
17
  /** The agent package's `fastagent.config.ts`, checked; `{}` without one. */
19
18
  export declare function loadConfig(dir: string): Promise<AgentConfig>;
20
19
  /** The default model a process here runs with: `--model` > `FASTAGENT_MODEL` > the configuration's `model`. */
21
20
  export declare function modelSpecFor(config: AgentConfig, flag: string | undefined, env?: NodeJS.ProcessEnv): string | undefined;
22
21
  /** Read the agent package in `dir`: `fastagent.config.ts` (optional) and `.fastagent/registry.ts`, generated first. */
23
22
  export declare function loadDefinition(directory: string): Promise<AgentDefinition>;
24
- /** The secrets the definition declares, wherever: its code's `needs`, and `context.json`'s command variables. */
25
23
  export declare function definitionSecrets(definition: AgentDefinition): DeclaredSecret[];
26
24
  /**
27
25
  * The list a person deploying or running the agent reads instead of its code (`.fastagent/secrets.json`): each name
@@ -65,10 +63,11 @@ export declare function buildDefinitionShape(definition: AgentDefinition, env: N
65
63
  export declare function definitionProviders(agentDir: string, env?: NodeJS.ProcessEnv): Promise<Provider[]>;
66
64
  export interface AssembleAgentOptions {
67
65
  storage: Storage;
68
- /** What the agent works on and knows (`context.json`'s content), resolved for this instance. */
66
+ /** What the agent works on (`context.json`'s content), resolved for this instance. */
69
67
  content?: readonly ResolvedContent[];
70
68
  /** The definition's code, built once with its secrets (`buildDefinition`). */
71
69
  code: BuiltDefinition;
70
+ mcpTools?: ToolRegistration[];
72
71
  /**
73
72
  * pi-ai's built-ins and the definition's providers over the instance's credentials, lists refreshed: the model
74
73
  * environment's (environment.ts), so the Agent runs on the lists its startup report reads.
@@ -19,12 +19,14 @@ import { createRegistry, defineExtension, } from "@earendil-works/pi-durable";
19
19
  import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
20
20
  import { CodingTools } from "@earendil-works/pi-durable/tools";
21
21
  import { loadEnvironment } from "../../content/file.js";
22
+ import { mcpSecrets } from "../../mcp/config.js";
22
23
  import { describeSecrets, missingSecrets } from "../../declared-secrets.js";
23
24
  import { declaredVariables } from "../../environment/declare.js";
24
25
  import { channelModuleProblem } from "../../channels/discover.js";
25
26
  import { AGENT_CONFIG_FILE as CONFIG_FILE } from "../../paths.js";
26
27
  import { readPackageJson } from "../../runtime.js";
27
28
  import { DEV_SERVE_ENV } from "../../serving-command.js";
29
+ import { modelRefOf } from "../../model-ref.js";
28
30
  import { MAX_SCHEDULES, scheduleConfigProblem } from "../../schedule/schedule.js";
29
31
  import { openDurableAgent } from "./agent.js";
30
32
  import { isNeeds } from "./define.js";
@@ -97,13 +99,6 @@ function checkConfig(config, file) {
97
99
  }
98
100
  return config;
99
101
  }
100
- export function modelRefOf(spec, where) {
101
- const slash = typeof spec === "string" ? spec.indexOf("/") : -1;
102
- if (slash < 1 || slash === spec.length - 1) {
103
- throw new Error(`${where} must be "provider/modelId", got ${JSON.stringify(spec)}`);
104
- }
105
- return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
106
- }
107
102
  /** The agent package's `fastagent.config.ts`, checked; `{}` without one. */
108
103
  export async function loadConfig(dir) {
109
104
  const configPath = join(dir, CONFIG_FILE);
@@ -146,12 +141,15 @@ function declarers(definition) {
146
141
  ];
147
142
  }
148
143
  /** The secrets the definition declares, wherever: its code's `needs`, and `context.json`'s command variables. */
149
- export function definitionSecrets(definition) {
144
+ function codeSecrets(definition) {
150
145
  return [
151
146
  ...declarers(definition).flatMap(({ source, value }) => isNeeds(value) ? value.secrets.map((secret) => ({ ...secret, source })) : []),
152
147
  ...declaredVariables(loadEnvironment(definition.dir)),
153
148
  ];
154
149
  }
150
+ export function definitionSecrets(definition) {
151
+ return [...codeSecrets(definition), ...mcpSecrets(definition.dir)];
152
+ }
155
153
  /**
156
154
  * The list a person deploying or running the agent reads instead of its code (`.fastagent/secrets.json`): each name
157
155
  * once, what it is for, whether the agent runs without it, and where it is declared.
@@ -205,7 +203,7 @@ const scheduleProblem = (value) => value?.kind !== "fastagent.schedule"
205
203
  * the values it declared, and refuse one that is not what its directory holds.
206
204
  */
207
205
  export function buildDefinition(definition, env = process.env) {
208
- const missing = missingSecrets(definitionSecrets(definition), env);
206
+ const missing = missingSecrets(codeSecrets(definition), env);
209
207
  if (missing.length > 0) {
210
208
  throw new Error(`missing required secrets: ${describeSecrets(missing)} (set them in the environment)`);
211
209
  }
@@ -299,13 +297,18 @@ export async function assembleAgent(definition, options) {
299
297
  throw new Error(`unknown model "${spec}": no provider "${model.provider}" offers "${model.modelId}"${stale}`);
300
298
  }
301
299
  const ownWake = [...code.tools, ...code.extensions.flatMap((extension) => extension.tools ?? [])].some(({ name }) => WAKE_TOOLS.includes(name));
300
+ const authoredNames = new Set([...code.tools, ...code.extensions.flatMap((extension) => extension.tools ?? [])].map((tool) => tool.name));
301
+ for (const tool of options.mcpTools ?? []) {
302
+ if (authoredNames.has(tool.name))
303
+ throw new Error(`MCP tool ${tool.name} conflicts with an authored tool`);
304
+ }
302
305
  const registry = createRegistry();
303
306
  registry.install(CodingTools);
304
307
  // After the coding tools, so a tool of the definition's own with one of their names replaces it.
305
308
  registry.install(defineExtension({
306
309
  name: DEFINITION_EXTENSION,
307
310
  sections: definitionSections(dir, options.content ?? [], { wake: ownWake || options.stateRoot !== undefined }),
308
- tools: code.tools,
311
+ tools: [...code.tools, ...(options.mcpTools ?? [])],
309
312
  }));
310
313
  for (const extension of code.extensions)
311
314
  registry.install(extension);
@@ -3,6 +3,7 @@ import { type Harness } from "@earendil-works/pi-durable";
3
3
  import type { CredentialSourceOptions } from "../models/credentials.ts";
4
4
  import { type ModelEnvironment } from "../models/environment.ts";
5
5
  import type { CredentialStore } from "../../sign-in-api.ts";
6
+ import type { McpOptions } from "../../mcp-api.ts";
6
7
  import { type ResolvedContent } from "../../content/resolve.ts";
7
8
  import type { Agent } from "../../agent-api.ts";
8
9
  import { type Service } from "../../service.ts";
@@ -10,6 +11,7 @@ import type { DurableAgent, Unfinished } from "./agent.ts";
10
11
  import { type AgentDefinition, type BuiltDefinition } from "./open.ts";
11
12
  import { type StorageLocking } from "./storage.ts";
12
13
  export interface OpenServingOptions extends CredentialSourceOptions {
14
+ mcp?: McpOptions;
13
15
  /** What a deployment sets over the definition's defaults (a flag, `FASTAGENT_MODEL`). */
14
16
  model?: string;
15
17
  thinkingLevel?: ModelThinkingLevel;
@@ -39,7 +41,7 @@ export interface OpenedServing {
39
41
  definition: AgentDefinition;
40
42
  code: BuiltDefinition;
41
43
  models: ModelEnvironment;
42
- /** What the agent works on and knows, resolved for this instance. */
44
+ /** What the agent works on, resolved for this instance. */
43
45
  content: readonly ResolvedContent[];
44
46
  agentDir: string;
45
47
  /** Where the instance's state is: its storage, its channels' and schedules' state, its wake-ups. */
@@ -71,6 +73,8 @@ export interface ServeAgentOptions {
71
73
  apiKeys?: readonly string[];
72
74
  }
73
75
  export interface OpenAgentOptions {
76
+ /** Per-instance MCP grants and interpolation values; no global credential discovery. */
77
+ mcp?: McpOptions;
74
78
  /** `"provider/modelId"`, over the definition's: what a session that names none answers with. */
75
79
  model?: string;
76
80
  /** Where provider credentials are kept, instead of the agent's own `.secrets/auth.json` over the machine's. */
@@ -9,6 +9,10 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
9
9
  import { MemoryStorage } from "@earendil-works/pi-durable";
10
10
  import { modelEnvironment } from "../models/environment.js";
11
11
  import { piCredentials } from "../models/host.js";
12
+ import { openMcpClients } from "../../mcp/client.js";
13
+ import { resolveMcpServers } from "../../mcp/config.js";
14
+ import { mcpAuthorization } from "../../mcp/host.js";
15
+ import { mcpTools } from "./mcp.js";
12
16
  import { loadContent } from "../../content/file.js";
13
17
  import { absentNotice, cloneContent, contentAbsentHere, resolveContent, } from "../../content/resolve.js";
14
18
  import { agentValues } from "../../value-file.js";
@@ -53,7 +57,7 @@ const instanceStorageFile = (stateRoot) => join(stateRoot, "agent.sqlite");
53
57
  export async function holdingInstance(stateRoot, work) {
54
58
  const storage = await openLockedStorage(instanceStorageFile(stateRoot), "posix", INSTANCE_HOLDERS);
55
59
  try {
56
- return work();
60
+ return await work();
57
61
  }
58
62
  finally {
59
63
  await storage.close(BACKGROUND_CONTEXT);
@@ -84,23 +88,48 @@ export async function openServing(directory, options = {}) {
84
88
  }, code.providers, { stateRoot, environment });
85
89
  const chosen = options.storage ?? { instance: "posix" };
86
90
  const storage = await openStorage(stateRoot, chosen);
87
- const agent = await assembleAgent(definition, {
88
- storage,
89
- code,
90
- content,
91
- environment,
92
- models: models.models,
93
- ...(models.catalogProblem === undefined ? {} : { catalogProblem: models.catalogProblem }),
94
- ...(typeof chosen === "object" && "instance" in chosen ? { stateRoot } : {}),
95
- ...(options.model === undefined ? {} : { model: options.model }),
96
- ...(options.thinkingLevel === undefined ? {} : { thinkingLevel: options.thinkingLevel }),
97
- ...(options.unfinished === undefined ? {} : { unfinished: options.unfinished }),
98
- ...(options.wakeTicking === undefined ? {} : { wakeTicking: options.wakeTicking }),
99
- }).catch(async (error) => {
100
- // An Agent that did not open does not own the storage, and its lock would refuse this process's next attempt.
101
- await storage.close(BACKGROUND_CONTEXT);
102
- throw error;
91
+ let mcp;
92
+ let agent;
93
+ try {
94
+ const mcpEnvironment = { ...env, ...options.mcp?.variables };
95
+ mcp = await openMcpClients(resolveMcpServers(dir, mcpEnvironment), mcpAuthorization(dir, env, stateRoot, options.mcp), options.mcp);
96
+ agent = await assembleAgent(definition, {
97
+ mcpTools: mcpTools(mcp.tools),
98
+ storage,
99
+ code,
100
+ content,
101
+ environment,
102
+ models: models.models,
103
+ ...(models.catalogProblem === undefined ? {} : { catalogProblem: models.catalogProblem }),
104
+ ...(typeof chosen === "object" && "instance" in chosen ? { stateRoot } : {}),
105
+ ...(options.model === undefined ? {} : { model: options.model }),
106
+ ...(options.thinkingLevel === undefined ? {} : { thinkingLevel: options.thinkingLevel }),
107
+ ...(options.unfinished === undefined ? {} : { unfinished: options.unfinished }),
108
+ ...(options.wakeTicking === undefined ? {} : { wakeTicking: options.wakeTicking }),
109
+ });
110
+ }
111
+ catch (error) {
112
+ const failures = [error];
113
+ if (mcp)
114
+ await mcp.close().catch((cleanup) => failures.push(cleanup));
115
+ await storage.close(BACKGROUND_CONTEXT).catch((cleanup) => failures.push(cleanup));
116
+ throw failures.length === 1 ? error : new AggregateError(failures, "Agent open and cleanup failed");
117
+ }
118
+ // A storage failure or a direct harness close also ends the resources this open acquired.
119
+ void agent.harness.closed
120
+ .then(() => mcp.close())
121
+ .catch((error) => {
122
+ log.warn(`[fastagent] MCP cleanup after harness shutdown failed: ${String(error)}`);
103
123
  });
124
+ const closeAgent = agent.close.bind(agent);
125
+ let closing;
126
+ agent.close = () => (closing ??= (async () => {
127
+ const failures = [];
128
+ await closeAgent().catch((error) => failures.push(error));
129
+ await mcp.close().catch((error) => failures.push(error));
130
+ if (failures.length)
131
+ throw new AggregateError(failures, "Agent close failed");
132
+ })());
104
133
  return {
105
134
  agent,
106
135
  definition,
@@ -130,6 +159,7 @@ export async function openAgent(dir, options = {}) {
130
159
  let ticking = false;
131
160
  const opened = await openServing(dir, {
132
161
  wakeTicking: () => ticking,
162
+ ...(options.mcp === undefined ? {} : { mcp: options.mcp }),
133
163
  ...(options.model === undefined ? {} : { model: options.model }),
134
164
  ...(options.credentials === undefined ? {} : { credentialStore: piCredentials(options.credentials) }),
135
165
  ...(options.stateDir === undefined ? {} : { stateRoot: stateDirOf(options.stateDir) }),
@@ -263,14 +263,12 @@ function contentKind(c) {
263
263
  // changes stays, and reaches anyone else only when pushed. The declared ref is what it follows, not a claim about
264
264
  // where it is: a clone the agent moved elsewhere is left there.
265
265
  const clone = `a shallow clone of github ${c.repo}${c.ref ? ` (declared ${c.ref})` : ""} in your own storage`;
266
- return c.readonly
267
- ? `${clone}, brought up to date each time you start`
268
- : `${clone}: what you change in it stays, and each time you start it is brought up to date where that touches nothing of yours; push to share a change`;
266
+ return `${clone}: what you change in it stays, and each time you start it is brought up to date where that touches nothing of yours; push to share a change`;
269
267
  }
270
268
  /**
271
269
  * Where the agent is and what it works on (agent-model.md §4): its own directory is itself, and each content entry is named
272
- * with its location, whether it is the agent's to change, and where a change there goes. A result written where it
273
- * does not last is how knowledge gets lost, so the agent is told which is which.
270
+ * with its location and where a change there goes. A result written where it does not last is how knowledge gets
271
+ * lost, so the agent is told which is which.
274
272
  */
275
273
  function contentSection({ agentDir, content }) {
276
274
  const own = `Your working directory, ${agentDir}, is your own directory: it holds your definition, and files you create ` +
@@ -278,15 +276,11 @@ function contentSection({ agentDir, content }) {
278
276
  if (content.length === 0)
279
277
  return `${own} Nothing else is declared for you: you work only in your own directory.`;
280
278
  const line = (c) => `- ${c.name}: ${c.location} (${contentKind(c)})${c.description !== undefined ? ` ${c.description}` : ""}`;
281
- const worksOn = content.filter((c) => !c.readonly);
282
- const knows = content.filter((c) => c.readonly);
283
279
  return [
284
280
  own,
285
- ...(worksOn.length > 0 ? ["You work on:", ...worksOn.map(line)] : []),
286
- ...(knows.length > 0 ? ["You know, and do not write:", ...knows.map(line)] : []),
287
- `Run a command in one of these directories with \`cd <its location> && …\`; file tools take the location directly.` +
288
- (worksOn.length > 0
289
- ? " A result worth keeping belongs in a directory you work on, not in your own directory."
290
- : " You write only in your own directory: the directories above are for reading."),
281
+ "You work on:",
282
+ ...content.map(line),
283
+ `Run a command in one of these directories with \`cd <its location> && …\`; file tools take the location directly. ` +
284
+ `A result worth keeping belongs in a directory you work on, not in your own directory.`,
291
285
  ].join("\n");
292
286
  }
@@ -464,9 +464,6 @@ declare const AgentHttpApi_base: HttpApi.HttpApi<"fastagent", HttpApiGroup.HttpA
464
464
  readonly event: Schema.String;
465
465
  readonly data: Schema.String;
466
466
  }>, never, never>, Schema.Never, {
467
- readonly type: "message";
468
- readonly message: Message;
469
- } | {
470
467
  readonly type: "entry";
471
468
  readonly entry: Entry;
472
469
  } | {
@@ -502,6 +499,9 @@ declare const AgentHttpApi_base: HttpApi.HttpApi<"fastagent", HttpApiGroup.HttpA
502
499
  readonly retryable: boolean;
503
500
  readonly details?: Schema.Json | undefined;
504
501
  };
502
+ } | {
503
+ readonly type: "message";
504
+ readonly message: Message;
505
505
  } | {
506
506
  readonly type: string;
507
507
  }> | Schema.toCodecJson<typeof Message>, Schema.toCodecJson<Schema.decodeTo<Schema.declare<AgentError, AgentError>, Schema.Struct<{
package/dist/index.d.ts CHANGED
@@ -7,9 +7,17 @@ export type { Agent, AgentErrorData, AgentInfo, CompactOptions, ContentPart, Cre
7
7
  export { AgentError, LIMITS, hasEnded } from "./agent-api.ts";
8
8
  export { openAgent, type LocalAgent, type OpenAgentOptions, type ServeAgentOptions, } from "./harnesses/pi-durable/serve.ts";
9
9
  export type { Service } from "./service.ts";
10
- export { createAgent, type CreatedAgent } from "./authoring.ts";
10
+ export { ContentNameError, addContent, createAgent, listContent, removeContent, type ContentEdit, type CreatedAgent, } from "./authoring.ts";
11
+ export type { ContentAddition, SourceOptions } from "./content/source.ts";
12
+ export { readContentSource } from "./content/source.ts";
13
+ export type { BoundContent, ContentBinding, ContentEntry } from "./content/declare.ts";
14
+ export type { ResolvedContent } from "./content/resolve.ts";
11
15
  export { SignInCancelled, type Credential, type CredentialStore, type ProviderAuth, type SignInEvent, type SignInInteraction, type SignInMethod, type SignInOption, type SignInPrompt, type SignInRequest, type SignInResult, } from "./sign-in-api.ts";
12
16
  export { credentialFile, providerAuth, signIn, signInOptions } from "./harnesses/models/host.ts";
17
+ export { McpError, type McpErrorCode, type McpOptions, type McpAuthorization, type McpGrantRecord, type McpGrantSummary, type McpGrantStore, type McpGrantAccess, type McpServerSummary, type McpSignInRequest, type McpSignInInteraction, } from "./mcp-api.ts";
18
+ export { inspectMcp } from "./mcp/host.ts";
19
+ export { signInMcp } from "./mcp/sign-in.ts";
20
+ export { mcpGrantFile } from "./mcp/grants.ts";
13
21
  export { defineAgent, defineSchedule, needs, type AgentConfig, type Needs, type SecretSpec, type SecretValues, } from "./harnesses/pi-durable/define.ts";
14
22
  export type { ScheduleConfig } from "./schedule/schedule.ts";
15
23
  export { defineChannel, type ChannelContext, type ChannelHandler, type ChannelModule, type ForkSource, type LongConnection, type LongConnectionChannelModule, type Routes, } from "./channel.ts";
package/dist/index.js CHANGED
@@ -1,8 +1,13 @@
1
1
  export { AgentError, LIMITS, hasEnded } from "./agent-api.js";
2
2
  export { openAgent, } from "./harnesses/pi-durable/serve.js";
3
- export { createAgent } from "./authoring.js";
3
+ export { ContentNameError, addContent, createAgent, listContent, removeContent, } from "./authoring.js";
4
+ export { readContentSource } from "./content/source.js";
4
5
  export { SignInCancelled, } from "./sign-in-api.js";
5
6
  export { credentialFile, providerAuth, signIn, signInOptions } from "./harnesses/models/host.js";
7
+ export { McpError, } from "./mcp-api.js";
8
+ export { inspectMcp } from "./mcp/host.js";
9
+ export { signInMcp } from "./mcp/sign-in.js";
10
+ export { mcpGrantFile } from "./mcp/grants.js";
6
11
  export { defineAgent, defineSchedule, needs, } from "./harnesses/pi-durable/define.js";
7
12
  export { defineChannel, } from "./channel.js";
8
13
  // What a channel reads a request with, answers with, and compares its platform's secret with (constant time).
@@ -3,8 +3,8 @@ export interface LockResult<T> {
3
3
  /** New file content to persist under the lock; undefined = no write. */
4
4
  next?: string;
5
5
  }
6
- /** Serialized cross-process read-modify-write of one file, replaced whole so its readers need no lock. */
7
- export declare function withLockedFile<T>(path: string, fn: (current: string | undefined) => Promise<LockResult<T>>,
6
+ /** Serialized read-modify-write. `replace` commits immediately under the lock; those writes survive a later failure. */
7
+ export declare function withLockedFile<T>(path: string, fn: (current: string | undefined, replace: (next: string) => void) => Promise<LockResult<T>>,
8
8
  /**
9
9
  * `mode`: the replaced file's permissions; default {@link SECRET_FILE_MODE}, for the credential and catalog files.
10
10
  * `create: false` locks a file that may not exist without creating it (the lock is `<file>.lock` either way), for a
@@ -26,7 +26,7 @@ function writeTarget(path) {
26
26
  // ever shows up in a real layout.
27
27
  return link?.isSymbolicLink() ? resolve(dirname(path), readlinkSync(path)) : path;
28
28
  }
29
- /** Serialized cross-process read-modify-write of one file, replaced whole so its readers need no lock. */
29
+ /** Serialized read-modify-write. `replace` commits immediately under the lock; those writes survive a later failure. */
30
30
  export async function withLockedFile(path, fn,
31
31
  /**
32
32
  * `mode`: the replaced file's permissions; default {@link SECRET_FILE_MODE}, for the credential and catalog files.
@@ -71,7 +71,12 @@ options = {}) {
71
71
  try {
72
72
  throwIfCompromised();
73
73
  const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
74
- const out = await fn(current);
74
+ const replace = (next) => {
75
+ throwIfCompromised();
76
+ options.signal?.throwIfAborted();
77
+ writeFileAtomic(writeTarget(path), next, options.mode ?? SECRET_FILE_MODE);
78
+ };
79
+ const out = await fn(current, replace);
75
80
  throwIfCompromised();
76
81
  // The last moment a caller that gave up can keep its change off disk: waiting for the lock may have outlasted it
77
82
  // (another process held the lock through the caller's timeout or cancel).
@@ -79,7 +84,7 @@ options = {}) {
79
84
  // Rename, not an in-place rewrite: it is what lets `read` stay unlocked, and it is the only spelling that applies
80
85
  // the mode before the content is reachable.
81
86
  if (out.next !== undefined) {
82
- writeFileAtomic(writeTarget(path), out.next, options.mode ?? SECRET_FILE_MODE);
87
+ replace(out.next);
83
88
  }
84
89
  throwIfCompromised();
85
90
  result = out.result;
@@ -0,0 +1,22 @@
1
+ import { type AuthorizationServerMetadata, type OAuthProtectedResourceMetadata } from "@modelcontextprotocol/client";
2
+ import { McpError, type McpGrantRecord, type McpGrantStore } from "../mcp-api.ts";
3
+ export interface McpDiscovery {
4
+ metadata: AuthorizationServerMetadata;
5
+ resourceMetadata: OAuthProtectedResourceMetadata;
6
+ issuer: string;
7
+ resource: string;
8
+ }
9
+ export declare function discoverMcp(url: string, fetchImpl: typeof globalThis.fetch): Promise<McpDiscovery>;
10
+ export declare function checkGrantTarget(record: McpGrantRecord, url: string, discovery?: McpDiscovery): void;
11
+ export declare function mcpFailure(error: unknown, operation: string, server?: string): McpError;
12
+ export declare function grantToken(input: {
13
+ store: McpGrantStore;
14
+ id: string;
15
+ url: string;
16
+ fetch: typeof globalThis.fetch;
17
+ discovery?: McpDiscovery;
18
+ rejectedGeneration?: number;
19
+ }): Promise<{
20
+ token: string;
21
+ generation: number;
22
+ }>;
@@ -0,0 +1,180 @@
1
+ /** OAuth discovery and refresh policy. Background calls never start an interactive sign-in. */
2
+ import { getSystemErrorMap } from "node:util";
3
+ import { checkResourceAllowed, discoverOAuthProtectedResourceMetadata, discoverAuthorizationServerMetadata, extractWWWAuthenticateParams, refreshAuthorization, OAuthError, OAuthErrorCode, SdkError, SdkErrorCode, SdkHttpError, UnauthorizedError, InsufficientScopeError, IssuerMismatchError, } from "@modelcontextprotocol/client";
4
+ import { McpError } from "../mcp-api.js";
5
+ import { checkMcpTarget } from "./network.js";
6
+ import { checkedGrant } from "./grants.js";
7
+ export async function discoverMcp(url, fetchImpl) {
8
+ const challenge = await fetchImpl(url, { headers: { Accept: "application/json, text/event-stream" } });
9
+ await challenge.body?.cancel();
10
+ if (!challenge.ok && ![401, 403, 404, 405].includes(challenge.status))
11
+ throw new McpError("connection", `MCP resource probe failed (HTTP ${challenge.status})`);
12
+ const { resourceMetadataUrl } = extractWWWAuthenticateParams(challenge);
13
+ const hints = challenge.headers.get("www-authenticate")?.match(/\bresource_metadata\s*=/gi) ?? [];
14
+ if (hints.length > 1 || (hints.length === 1 && !resourceMetadataUrl))
15
+ throw new McpError("protocol", "Unusable MCP resource_metadata challenge; refusing default discovery");
16
+ if (resourceMetadataUrl)
17
+ checkMcpTarget(url, resourceMetadataUrl.href);
18
+ const resource = await discoverOAuthProtectedResourceMetadata(url, { resourceMetadataUrl }, fetchImpl);
19
+ const issuer = resource.authorization_servers?.[0] ?? new URL("/", url).href;
20
+ checkMcpTarget(url, issuer);
21
+ const metadata = await discoverAuthorizationServerMetadata(issuer, { fetchFn: fetchImpl });
22
+ if (!metadata ||
23
+ !resource ||
24
+ !checkResourceAllowed({ requestedResource: new URL(url), configuredResource: resource.resource })) {
25
+ throw new McpError("authorization_mismatch", "MCP authorization discovery did not identify the configured resource and issuer");
26
+ }
27
+ checkMcpTarget(url, metadata.issuer);
28
+ checkMcpTarget(url, metadata.authorization_endpoint);
29
+ checkMcpTarget(url, metadata.token_endpoint);
30
+ if (metadata.registration_endpoint)
31
+ checkMcpTarget(url, metadata.registration_endpoint);
32
+ return { metadata, resourceMetadata: resource, resource: resource.resource, issuer: metadata.issuer };
33
+ }
34
+ export function checkGrantTarget(record, url, discovery) {
35
+ if (record.serverUrl !== url ||
36
+ (discovery && (record.issuer !== discovery.issuer || record.resource !== discovery.resource))) {
37
+ throw new McpError("authorization_mismatch", "MCP grant belongs to a different endpoint, resource or issuer; sign in explicitly for this server");
38
+ }
39
+ }
40
+ const safeReasons = new Map([
41
+ ...getSystemErrorMap().values(),
42
+ ["ENOTFOUND", "DNS name not found"],
43
+ ["ERR_SSL_WRONG_VERSION_NUMBER", "TLS peer did not speak the expected protocol"],
44
+ ["UND_ERR_CONNECT_TIMEOUT", "connection timed out"],
45
+ ["UND_ERR_HEADERS_TIMEOUT", "response headers timed out"],
46
+ ["UND_ERR_BODY_TIMEOUT", "response body timed out"],
47
+ ["UND_ERR_SOCKET", "socket closed or failed"],
48
+ ["UND_ERR_CLOSED", "HTTP client closed"],
49
+ ["UND_ERR_DESTROYED", "HTTP client destroyed"],
50
+ ["ERR_TLS_CERT_ALTNAME_INVALID", "TLS certificate hostname mismatch"],
51
+ ["CERT_HAS_EXPIRED", "TLS certificate expired"],
52
+ ["DEPTH_ZERO_SELF_SIGNED_CERT", "untrusted self-signed TLS certificate"],
53
+ ["SELF_SIGNED_CERT_IN_CHAIN", "untrusted TLS certificate chain"],
54
+ ["UNABLE_TO_VERIFY_LEAF_SIGNATURE", "TLS certificate could not be verified"],
55
+ ["UNABLE_TO_GET_ISSUER_CERT_LOCALLY", "TLS certificate issuer is not trusted"],
56
+ ]);
57
+ const sdkCodes = new Set([...Object.values(SdkErrorCode), ...Object.values(OAuthErrorCode)]);
58
+ const errorNames = new Set([
59
+ "Error",
60
+ "TypeError",
61
+ "SyntaxError",
62
+ "RangeError",
63
+ "ReferenceError",
64
+ "URIError",
65
+ "EvalError",
66
+ "AbortError",
67
+ "TimeoutError",
68
+ "AggregateError",
69
+ "OAuthError",
70
+ "SdkError",
71
+ ]);
72
+ function safeFailureReason(error) {
73
+ const pending = [error];
74
+ const seen = new Set();
75
+ const reasons = new Set();
76
+ while (pending.length) {
77
+ const current = pending.pop();
78
+ if (!(current instanceof Error) || seen.has(current))
79
+ continue;
80
+ seen.add(current);
81
+ const code = "code" in current && typeof current.code === "string" ? current.code : undefined;
82
+ if (code !== undefined) {
83
+ const reason = safeReasons.get(code);
84
+ if (reason !== undefined)
85
+ reasons.add(`${code}: ${reason}`);
86
+ else if (sdkCodes.has(code))
87
+ reasons.add(code);
88
+ }
89
+ // SDK 2.4's pinned stdio probe omits structured close data; recognize only its fixed diagnostic prefix.
90
+ if (current.message === "Connection closed during the version negotiation probe" ||
91
+ (current instanceof SdkError &&
92
+ current.code === SdkErrorCode.EraNegotiationFailed &&
93
+ current.message.startsWith("Version negotiation failed: the connection closed during the server/discover probe before the server offered pinned protocol version ")))
94
+ reasons.add("CONNECTION_CLOSED: server closed during protocol discovery");
95
+ if (current instanceof SdkHttpError &&
96
+ Number.isInteger(current.status) &&
97
+ current.status >= 100 &&
98
+ current.status <= 599)
99
+ reasons.add(`HTTP ${current.status}`);
100
+ pending.push(current.cause);
101
+ if (current instanceof AggregateError && Array.isArray(current.errors))
102
+ pending.push(...current.errors);
103
+ }
104
+ if (reasons.size)
105
+ return [...reasons].join("; ");
106
+ return error instanceof Error ? (errorNames.has(error.name) ? error.name : "Error") : "unknown error";
107
+ }
108
+ export function mcpFailure(error, operation, server) {
109
+ if (error instanceof McpError)
110
+ return error.server || !server ? error : new McpError(error.code, error.message, server);
111
+ if (error instanceof IssuerMismatchError)
112
+ return new McpError("authorization_mismatch", `${operation}: OAuth issuer validation failed`, server);
113
+ if (error instanceof UnauthorizedError || (error instanceof SdkHttpError && error.status === 401))
114
+ return new McpError("authorization_required", `${operation}: MCP authorization required; sign in for this server`, server);
115
+ if (error instanceof InsufficientScopeError || (error instanceof SdkHttpError && error.status === 403))
116
+ return new McpError("insufficient_scope", `${operation}: MCP access refused (HTTP 403); review the grant permissions`, server);
117
+ // Error messages, paths, args, stderr and raw causes can contain credentials; only allowlisted facts escape.
118
+ return new McpError("connection", `${operation} failed (${safeFailureReason(error)})`, server);
119
+ }
120
+ export async function grantToken(input) {
121
+ return input.store.withGrant(input.id, async (access) => {
122
+ const raw = access.read();
123
+ if (!raw)
124
+ throw new McpError("authorization_required", "No MCP grant is stored; sign in before opening this agent");
125
+ const record = checkedGrant(raw);
126
+ checkGrantTarget(record, input.url, input.discovery);
127
+ if (record.status !== "ready")
128
+ throw new McpError(record.status === "refreshing" ? "refresh_uncertain" : "authorization_required", "MCP authorization must be renewed; the previous grant cannot be refreshed safely");
129
+ const fresh = record.expiresAt === undefined || record.expiresAt > Date.now() + 30_000;
130
+ if (fresh && (input.rejectedGeneration === undefined || record.generation !== input.rejectedGeneration)) {
131
+ return { token: record.accessToken, generation: record.generation };
132
+ }
133
+ if (!record.refreshToken) {
134
+ await access.write({ ...record, status: "reauthorization_required" });
135
+ throw new McpError("authorization_required", "MCP authorization expired and has no refresh token; sign in again");
136
+ }
137
+ const discovery = await discoverMcp(input.url, input.fetch);
138
+ checkGrantTarget(record, input.url, discovery);
139
+ // Persist before the remote server can consume the old token. A lost response cannot be replayed safely.
140
+ await access.write({ ...record, status: "refreshing" });
141
+ let tokens;
142
+ try {
143
+ tokens = await refreshAuthorization(record.issuer, {
144
+ metadata: discovery.metadata,
145
+ clientInformation: {
146
+ client_id: record.client.id,
147
+ ...(record.client.secret === undefined ? {} : { client_secret: record.client.secret }),
148
+ token_endpoint_auth_method: record.client.tokenEndpointAuthMethod,
149
+ },
150
+ refreshToken: record.refreshToken,
151
+ resource: record.resource,
152
+ fetchFn: input.fetch,
153
+ });
154
+ }
155
+ catch (error) {
156
+ if (error instanceof OAuthError &&
157
+ ["invalid_grant", "invalid_client", "unauthorized_client"].includes(error.code)) {
158
+ await access.write({ ...record, status: "reauthorization_required" });
159
+ throw new McpError("authorization_required", "The MCP authorization server refused the refresh; sign in again");
160
+ }
161
+ throw mcpFailure(error, "MCP token refresh");
162
+ }
163
+ const scopes = tokens.scope === undefined ? record.scopes : tokens.scope.split(/\s+/).filter(Boolean);
164
+ if (scopes.some((scope) => !record.scopes.includes(scope)))
165
+ throw new McpError("insufficient_scope", "MCP refresh unexpectedly expanded the grant's scopes");
166
+ const next = {
167
+ ...record,
168
+ scopes,
169
+ status: "ready",
170
+ generation: record.generation + 1,
171
+ accessToken: tokens.access_token,
172
+ ...(tokens.refresh_token === undefined ? {} : { refreshToken: tokens.refresh_token }),
173
+ expiresAt: tokens.expires_in === undefined ? undefined : Date.now() + tokens.expires_in * 1000,
174
+ };
175
+ if (tokens.token_type.toLowerCase() !== "bearer")
176
+ throw new McpError("protocol", "Only bearer MCP grants are supported");
177
+ await access.write(next);
178
+ return { token: next.accessToken, generation: next.generation };
179
+ });
180
+ }
@@ -0,0 +1,15 @@
1
+ /** Instance-owned MCP clients. The scope also joins refreshes after a caller abandons its wait. */
2
+ import { type Tool, type CallToolResult } from "@modelcontextprotocol/client";
3
+ import { type McpAuthorization, type McpOptions } from "../mcp-api.ts";
4
+ import type { McpServer } from "./config.ts";
5
+ export interface McpTool {
6
+ name: string;
7
+ server: string;
8
+ definition: Tool;
9
+ call(args: Record<string, unknown>, signal?: AbortSignal, progress?: (text: string) => void): Promise<CallToolResult>;
10
+ }
11
+ export interface McpClients {
12
+ tools: McpTool[];
13
+ close(): Promise<void>;
14
+ }
15
+ export declare function openMcpClients(servers: readonly McpServer[], auth: McpAuthorization, options?: Pick<McpOptions, "fetch">): Promise<McpClients>;