@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
@@ -0,0 +1,170 @@
1
+ /** Instance-owned MCP clients. The scope also joins refreshes after a caller abandons its wait. */
2
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
3
+ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Exit from "effect/Exit";
6
+ import * as Fiber from "effect/Fiber";
7
+ import * as Scope from "effect/Scope";
8
+ import { portAbort, portJoin, portError } from "../effect-port.js";
9
+ import { McpError } from "../mcp-api.js";
10
+ import { checkGrantTarget, discoverMcp, grantToken, mcpFailure } from "./auth.js";
11
+ import { mcpFetch, openMcpNetwork } from "./network.js";
12
+ import { checkedGrant } from "./grants.js";
13
+ import { log } from "../log.js";
14
+ export async function openMcpClients(servers, auth, options = {}) {
15
+ const lifetime = Scope.makeUnsafe();
16
+ const cleanupErrors = [];
17
+ const tools = [];
18
+ const names = new Set();
19
+ let closing;
20
+ const run = async (work) => {
21
+ if (closing)
22
+ throw new McpError("connection", "MCP clients are closed");
23
+ const result = await Effect.runPromiseExit(Effect.forkIn(work, lifetime).pipe(Effect.flatMap(Fiber.join)));
24
+ if (Exit.isFailure(result))
25
+ throw portError(result.cause);
26
+ return result.value;
27
+ };
28
+ const close = () => (closing ??= (async () => {
29
+ await Effect.runPromise(Scope.close(lifetime, Exit.void));
30
+ if (cleanupErrors.length)
31
+ throw new AggregateError(cleanupErrors, "MCP cleanup failed");
32
+ })());
33
+ let openingServer;
34
+ try {
35
+ for (const server of servers) {
36
+ openingServer = server.name;
37
+ const client = new Client({ name: "FastAgent", version: "1" }, { versionNegotiation: { mode: { pin: "2026-07-28" }, probe: { timeoutMs: 15_000, maxRetries: 0 } } });
38
+ await Effect.runPromise(Scope.addFinalizer(lifetime, portJoin(() => client.close()).pipe(Effect.catchCause((cause) => Effect.sync(() => {
39
+ cleanupErrors.push(mcpFailure(portError(cause), "MCP cleanup", server.name));
40
+ })))));
41
+ client.onerror = (error) => log.warn(`[mcp] ${mcpFailure(error, "Protocol error", server.name).message}`);
42
+ let disconnected = false;
43
+ client.onclose = () => {
44
+ disconnected = true;
45
+ };
46
+ const grantId = Object.hasOwn(auth.bindings, server.name) ? auth.bindings[server.name] : undefined;
47
+ let transport;
48
+ if (server.type === "stdio") {
49
+ if (grantId !== undefined)
50
+ throw new McpError("configuration", "A stdio MCP server cannot have an OAuth grant binding", server.name);
51
+ transport = new StdioClientTransport({
52
+ command: server.command,
53
+ args: server.args,
54
+ cwd: server.cwd,
55
+ env: server.env,
56
+ stderr: "pipe",
57
+ });
58
+ // Draining prevents a noisy child from blocking. Raw stderr can contain its supplied secrets.
59
+ transport.stderr?.on("data", () => { });
60
+ }
61
+ else {
62
+ const network = await openMcpNetwork(server.url, options.fetch);
63
+ await Effect.runPromise(Scope.addFinalizer(lifetime, portJoin(() => network.close()).pipe(Effect.catchCause((cause) => Effect.sync(() => {
64
+ cleanupErrors.push(mcpFailure(portError(cause), "MCP network cleanup", server.name));
65
+ })))));
66
+ const fetchImpl = mcpFetch(server.url, network.fetch);
67
+ const requestFetch = mcpFetch(server.url, network.fetch, 60_000);
68
+ if (grantId !== undefined && new Headers(server.headers).has("authorization"))
69
+ throw new McpError("configuration", "Static Authorization and OAuth cannot be combined", server.name);
70
+ if (grantId !== undefined) {
71
+ const held = await auth.store.read(grantId);
72
+ if (!held)
73
+ throw new McpError("authorization_required", "No grant found for this MCP binding; sign in again", server.name);
74
+ checkGrantTarget(checkedGrant(held), server.url, await discoverMcp(server.url, fetchImpl));
75
+ }
76
+ const request = (input, init) => run(portJoin(async () => {
77
+ if (grantId === undefined)
78
+ return requestFetch(input, init);
79
+ const url = input instanceof Request ? input.url : String(input);
80
+ if (url !== server.url)
81
+ throw new McpError("authorization_mismatch", "Refusing to send an MCP token to another URL", server.name);
82
+ const token = await grantToken({ store: auth.store, id: grantId, url: server.url, fetch: fetchImpl });
83
+ init?.signal?.throwIfAborted();
84
+ const headers = new Headers(init?.headers);
85
+ headers.set("Authorization", `Bearer ${token.token}`);
86
+ const response = await requestFetch(input, { ...init, headers });
87
+ if (response.status !== 401)
88
+ return response;
89
+ await response.body?.cancel();
90
+ const refreshed = await grantToken({
91
+ store: auth.store,
92
+ id: grantId,
93
+ url: server.url,
94
+ fetch: fetchImpl,
95
+ rejectedGeneration: token.generation,
96
+ });
97
+ init?.signal?.throwIfAborted();
98
+ headers.set("Authorization", `Bearer ${refreshed.token}`);
99
+ const retry = await requestFetch(input, { ...init, headers });
100
+ if (retry.status === 401) {
101
+ await retry.body?.cancel();
102
+ await auth.store.withGrant(grantId, async (access) => {
103
+ const current = access.read();
104
+ if (current?.generation === refreshed.generation)
105
+ await access.write({ ...current, status: "reauthorization_required" });
106
+ });
107
+ throw new McpError("authorization_required", "MCP server refused the refreshed grant; sign in again", server.name);
108
+ }
109
+ return retry;
110
+ }));
111
+ transport = new StreamableHTTPClientTransport(new URL(server.url), {
112
+ fetch: request,
113
+ requestInit: { headers: server.headers },
114
+ onInsufficientScope: "throw",
115
+ });
116
+ }
117
+ // Register the transport separately: SDK discovery can fail before Client takes ownership of it.
118
+ await Effect.runPromise(Scope.addFinalizer(lifetime, portJoin(() => transport.close()).pipe(Effect.catchCause((cause) => Effect.sync(() => {
119
+ cleanupErrors.push(mcpFailure(portError(cause), "MCP transport cleanup", server.name));
120
+ })))));
121
+ await client.connect(transport, { timeout: 30_000 });
122
+ if (!client.getServerCapabilities()?.tools)
123
+ throw new McpError("protocol", "MCP server does not offer tools; other capabilities are not supported", server.name);
124
+ const listed = await client.listTools(undefined, { timeout: 30_000 });
125
+ for (const definition of listed.tools) {
126
+ const name = `mcp__${server.name}__${definition.name}`;
127
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name) || names.has(name))
128
+ throw new McpError("configuration", `Invalid or duplicate MCP tool name ${name}`, server.name);
129
+ names.add(name);
130
+ tools.push({
131
+ name,
132
+ server: server.name,
133
+ definition,
134
+ async call(args, signal, progress) {
135
+ if (disconnected)
136
+ throw new McpError("connection", "MCP server disconnected; reopen the agent to reconnect", server.name);
137
+ const abort = new AbortController();
138
+ const forwarded = signal ? AbortSignal.any([signal, abort.signal]) : abort.signal;
139
+ try {
140
+ return await run(portAbort("MCP tool", () => client.callTool({ name: definition.name, arguments: args }, {
141
+ signal: forwarded,
142
+ timeout: 60_000,
143
+ ...(progress
144
+ ? {
145
+ onprogress: (value) => progress(`MCP ${server.name}: ${value.progress}${value.total === undefined ? "" : `/${value.total}`}`),
146
+ }
147
+ : {}),
148
+ }), () => abort.abort()));
149
+ }
150
+ catch (error) {
151
+ if (forwarded.aborted)
152
+ throw forwarded.reason;
153
+ throw mcpFailure(error, "MCP tool call", server.name);
154
+ }
155
+ },
156
+ });
157
+ }
158
+ }
159
+ return { tools, close };
160
+ }
161
+ catch (error) {
162
+ try {
163
+ await close();
164
+ }
165
+ catch (cleanup) {
166
+ throw new AggregateError([mcpFailure(error, "MCP open", openingServer), cleanup], "MCP open and cleanup failed");
167
+ }
168
+ throw mcpFailure(error, "MCP open", openingServer);
169
+ }
170
+ }
@@ -0,0 +1,34 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { type DeclaredSecret } from "../declared-secrets.ts";
3
+ export declare const MCP_FILE = ".mcp.json";
4
+ declare const Configuration: Schema.Struct<{
5
+ readonly mcpServers: Schema.$Record<Schema.String, Schema.Union<readonly [Schema.Struct<{
6
+ readonly type: Schema.optional<Schema.Literal<"stdio">>;
7
+ readonly command: Schema.NonEmptyString;
8
+ readonly args: Schema.optional<Schema.$Array<Schema.String>>;
9
+ readonly env: Schema.optional<Schema.$Record<Schema.String, Schema.String>>;
10
+ }>, Schema.Struct<{
11
+ readonly type: Schema.Literal<"http">;
12
+ readonly url: Schema.String;
13
+ readonly headers: Schema.optional<Schema.$Record<Schema.String, Schema.String>>;
14
+ }>]>>;
15
+ }>;
16
+ export type McpConfiguration = typeof Configuration.Type;
17
+ export type McpServer = {
18
+ name: string;
19
+ type: "stdio";
20
+ command: string;
21
+ args: string[];
22
+ env: Record<string, string>;
23
+ cwd: string;
24
+ } | {
25
+ name: string;
26
+ type: "http";
27
+ url: string;
28
+ headers: Record<string, string>;
29
+ };
30
+ export declare function readMcpConfiguration(dir: string): McpConfiguration;
31
+ export declare function mcpSecrets(dir: string): DeclaredSecret[];
32
+ export declare function checkMcpUrl(value: string, server?: string): URL;
33
+ export declare function resolveMcpServers(dir: string, env: Readonly<Record<string, string | undefined>>, only?: string): McpServer[];
34
+ export {};
@@ -0,0 +1,142 @@
1
+ /** The sole MCP declaration: .mcp.json in the agent directory, resolved against this instance's environment. */
2
+ import { readFileSync } from "node:fs";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import * as Schema from "effect/Schema";
5
+ import * as SchemaIssue from "effect/SchemaIssue";
6
+ import { isEnvName } from "../declared-secrets.js";
7
+ import { McpError } from "../mcp-api.js";
8
+ export const MCP_FILE = ".mcp.json";
9
+ const Strings = Schema.Record(Schema.String, Schema.String);
10
+ const Stdio = Schema.Struct({
11
+ type: Schema.optional(Schema.Literal("stdio")),
12
+ command: Schema.NonEmptyString,
13
+ args: Schema.optional(Schema.Array(Schema.String)),
14
+ env: Schema.optional(Strings),
15
+ });
16
+ const Http = Schema.Struct({
17
+ type: Schema.Literal("http"),
18
+ url: Schema.String,
19
+ headers: Schema.optional(Strings),
20
+ });
21
+ const Configuration = Schema.Struct({ mcpServers: Schema.Record(Schema.String, Schema.Union([Stdio, Http])) });
22
+ export function readMcpConfiguration(dir) {
23
+ const file = join(dir, MCP_FILE);
24
+ let text;
25
+ try {
26
+ text = readFileSync(file, "utf8");
27
+ }
28
+ catch (error) {
29
+ if (error.code === "ENOENT")
30
+ return { mcpServers: {} };
31
+ throw error;
32
+ }
33
+ let config;
34
+ try {
35
+ config = Schema.decodeUnknownSync(Schema.fromJsonString(Configuration))(text, { onExcessProperty: "error" });
36
+ }
37
+ catch (error) {
38
+ const paths = error instanceof Error && SchemaIssue.isIssue(error.cause)
39
+ ? SchemaIssue.makeFormatterStandardSchemaV1()(error.cause).issues.map((issue) => issue.path)
40
+ : [];
41
+ throw new McpError("configuration", `${file}: invalid MCP configuration at ${JSON.stringify(paths)}; expected stdio command/args/env or http url/headers; unknown fields are refused`);
42
+ }
43
+ for (const [name, server] of Object.entries(config.mcpServers)) {
44
+ if (!/^[a-zA-Z0-9_-]+$/.test(name) || name.includes("__") || name.length > 40) {
45
+ throw new McpError("configuration", `${file}: server names must be 1–40 letters, digits, hyphens or underscores, without __`);
46
+ }
47
+ if (server.type === "http")
48
+ checkMcpUrl(server.url, name);
49
+ else
50
+ for (const key of Object.keys(server.env ?? {})) {
51
+ if (!isEnvName(key))
52
+ throw new McpError("configuration", `${file}: invalid environment variable name for ${name}`, name);
53
+ }
54
+ for (const value of configStrings(server))
55
+ variables(value, name);
56
+ }
57
+ return config;
58
+ }
59
+ function configStrings(server) {
60
+ return server.type === "http"
61
+ ? Object.values(server.headers ?? {})
62
+ : [server.command, ...(server.args ?? []), ...Object.values(server.env ?? {})];
63
+ }
64
+ function variables(value, server) {
65
+ const names = [];
66
+ const rest = value.replace(/\$\{([^}]*)\}/g, (_match, name) => {
67
+ if (!isEnvName(name))
68
+ throw new McpError("configuration", `${MCP_FILE}: ${server} supports only \${NAME} substitutions`, server);
69
+ names.push(name);
70
+ return "";
71
+ });
72
+ if (rest.includes("${") || rest.includes("$("))
73
+ throw new McpError("configuration", `${MCP_FILE}: unsupported substitution in ${server}`, server);
74
+ return names;
75
+ }
76
+ export function mcpSecrets(dir) {
77
+ return Object.entries(readMcpConfiguration(dir).mcpServers).flatMap(([server, config]) => configStrings(config).flatMap((value) => variables(value, server).map((name) => ({ name, source: `${MCP_FILE} mcpServers.${server}` }))));
78
+ }
79
+ export function checkMcpUrl(value, server) {
80
+ const url = URL.parse(value);
81
+ if (!url ||
82
+ value.includes("${") ||
83
+ value.includes("$(") ||
84
+ url.username ||
85
+ url.password ||
86
+ url.hash ||
87
+ (url.protocol !== "https:" &&
88
+ !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))) {
89
+ throw new McpError("configuration", `${MCP_FILE}: MCP endpoints require HTTPS (HTTP is allowed only on loopback), without userinfo or fragments`, server);
90
+ }
91
+ return url;
92
+ }
93
+ // These are execution necessities, not the other models' or servers' credentials.
94
+ const INHERITED = [
95
+ "PATH",
96
+ "HOME",
97
+ "USERPROFILE",
98
+ "SystemRoot",
99
+ "SYSTEMROOT",
100
+ "TEMP",
101
+ "TMP",
102
+ "TMPDIR",
103
+ "LANG",
104
+ "LC_ALL",
105
+ ];
106
+ export function resolveMcpServers(dir, env, only) {
107
+ const config = readMcpConfiguration(dir);
108
+ return Object.entries(config.mcpServers)
109
+ .filter(([name]) => only === undefined || name === only)
110
+ .map(([name, server]) => {
111
+ const expand = (value) => value.replace(/\$\{([^}]*)\}/g, (_match, key) => {
112
+ const result = env[key];
113
+ if (!result)
114
+ throw new McpError("configuration", `${MCP_FILE}: ${name} requires ${key}`, name);
115
+ return result;
116
+ });
117
+ if (server.type === "http") {
118
+ const headers = Object.fromEntries(Object.entries(server.headers ?? {}).map(([key, value]) => [key, expand(value)]));
119
+ try {
120
+ new Headers(headers);
121
+ }
122
+ catch {
123
+ throw new McpError("configuration", `${MCP_FILE}: invalid HTTP headers for ${name}`, name);
124
+ }
125
+ return { name, type: "http", url: checkMcpUrl(server.url, name).href, headers };
126
+ }
127
+ let command = expand(server.command);
128
+ if (!isAbsolute(command) && /[/\\]/.test(command))
129
+ command = resolve(dir, command);
130
+ return {
131
+ name,
132
+ type: "stdio",
133
+ command,
134
+ args: (server.args ?? []).map(expand),
135
+ cwd: resolve(dir),
136
+ env: {
137
+ ...Object.fromEntries(INHERITED.flatMap((key) => (env[key] === undefined ? [] : [[key, env[key]]]))),
138
+ ...Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, expand(value)])),
139
+ },
140
+ };
141
+ });
142
+ }
@@ -0,0 +1,4 @@
1
+ import { type McpGrantRecord, type McpGrantStore, type McpGrantSummary } from "../mcp-api.ts";
2
+ export declare function checkedGrant(value: unknown): McpGrantRecord;
3
+ export declare function grantSummary(id: string, record: McpGrantRecord): McpGrantSummary;
4
+ export declare function mcpGrantFile(path: string): McpGrantStore;
@@ -0,0 +1,132 @@
1
+ /** Durable OAuth grants: explicit writes under the same lock that guards refresh and deletion. */
2
+ import { lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import * as Schema from "effect/Schema";
5
+ import { withLockedFile } from "../locked-file.js";
6
+ import { McpError } from "../mcp-api.js";
7
+ const RecordSchema = Schema.Struct({
8
+ version: Schema.Literal(1),
9
+ serverUrl: Schema.NonEmptyString,
10
+ resource: Schema.NonEmptyString,
11
+ issuer: Schema.NonEmptyString,
12
+ client: Schema.Struct({
13
+ id: Schema.NonEmptyString,
14
+ secret: Schema.optional(Schema.String),
15
+ tokenEndpointAuthMethod: Schema.Literals(["none", "client_secret_post", "client_secret_basic"]),
16
+ }),
17
+ scopes: Schema.Array(Schema.NonEmptyString),
18
+ status: Schema.Literals(["ready", "refreshing", "reauthorization_required"]),
19
+ generation: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
20
+ accessToken: Schema.NonEmptyString,
21
+ refreshToken: Schema.optional(Schema.NonEmptyString),
22
+ expiresAt: Schema.optional(Schema.Number.check(Schema.isFinite())),
23
+ });
24
+ const FileSchema = Schema.Record(Schema.String, RecordSchema);
25
+ export function checkedGrant(value) {
26
+ try {
27
+ return Schema.decodeUnknownSync(RecordSchema)(value, { onExcessProperty: "error" });
28
+ }
29
+ catch {
30
+ throw new McpError("storage", "Invalid MCP grant record; refusing to read or overwrite credentials");
31
+ }
32
+ }
33
+ export function grantSummary(id, record) {
34
+ return {
35
+ id,
36
+ serverUrl: record.serverUrl,
37
+ resource: record.resource,
38
+ issuer: record.issuer,
39
+ scopes: [...record.scopes],
40
+ status: record.status,
41
+ ...(record.expiresAt === undefined ? {} : { expiresAt: record.expiresAt }),
42
+ };
43
+ }
44
+ function decode(raw, path) {
45
+ if (raw === undefined)
46
+ return {};
47
+ try {
48
+ return Schema.decodeUnknownSync(Schema.fromJsonString(FileSchema))(raw, { onExcessProperty: "error" });
49
+ }
50
+ catch {
51
+ throw new McpError("storage", `Invalid MCP grant file ${path}; refusing to overwrite it`);
52
+ }
53
+ }
54
+ function idIsValid(id) {
55
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(id) || ["__proto__", "constructor", "prototype"].includes(id)) {
56
+ throw new McpError("storage", "Invalid MCP grant ID");
57
+ }
58
+ }
59
+ function grantFileTarget(path) {
60
+ try {
61
+ return realpathSync(path);
62
+ }
63
+ catch (error) {
64
+ if (error.code !== "ENOENT")
65
+ throw error;
66
+ const link = lstatSync(path, { throwIfNoEntry: false });
67
+ return link?.isSymbolicLink()
68
+ ? grantFileTarget(resolve(dirname(path), readlinkSync(path)))
69
+ : resolve(grantFileTarget(dirname(path)), basename(path));
70
+ }
71
+ }
72
+ export function mcpGrantFile(path) {
73
+ const file = resolve(path);
74
+ const read = () => {
75
+ try {
76
+ return decode(readFileSync(file, "utf8"), file);
77
+ }
78
+ catch (error) {
79
+ if (error.code === "ENOENT")
80
+ return {};
81
+ throw error;
82
+ }
83
+ };
84
+ return {
85
+ async read(id) {
86
+ idIsValid(id);
87
+ const records = read();
88
+ return Object.hasOwn(records, id) ? records[id] : undefined;
89
+ },
90
+ async list() {
91
+ return Object.entries(read()).map(([id, value]) => grantSummary(id, value));
92
+ },
93
+ withGrant(id, operation) {
94
+ idIsValid(id);
95
+ // The shared lock helper preserves pi credential path spellings; MCP locks the backing file itself.
96
+ return withLockedFile(grantFileTarget(file), async (raw, replace) => {
97
+ let records = decode(raw, file);
98
+ const save = (next) => {
99
+ replace(`${JSON.stringify(next)}\n`);
100
+ records = next;
101
+ };
102
+ let active = true;
103
+ const assertActive = () => {
104
+ if (!active)
105
+ throw new McpError("storage", "MCP grant access escaped its exclusive scope");
106
+ };
107
+ try {
108
+ const result = await operation({
109
+ read() {
110
+ assertActive();
111
+ return Object.hasOwn(records, id) ? checkedGrant(records[id]) : undefined;
112
+ },
113
+ async write(value) {
114
+ assertActive();
115
+ save({ ...records, [id]: checkedGrant(value) });
116
+ },
117
+ async remove() {
118
+ assertActive();
119
+ const next = { ...records };
120
+ delete next[id];
121
+ save(next);
122
+ },
123
+ });
124
+ return { result };
125
+ }
126
+ finally {
127
+ active = false;
128
+ }
129
+ }, { create: false });
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,16 @@
1
+ import { type McpAuthorization, type McpOptions, type McpServerSummary } from "../mcp-api.ts";
2
+ import { type McpServer } from "./config.ts";
3
+ export declare function mcpAuthorization(dir: string, env: NodeJS.ProcessEnv, stateRoot: string, options?: McpOptions): McpAuthorization;
4
+ export declare function writeMcpBinding(stateRoot: string, server: string, id: string | undefined): Promise<void>;
5
+ export declare function mcpContext(dir: string, options?: McpOptions): {
6
+ agentDir: string;
7
+ environment: {
8
+ [x: string]: string | undefined;
9
+ };
10
+ stateRoot: string;
11
+ auth: McpAuthorization;
12
+ };
13
+ export declare function httpServer(servers: readonly McpServer[], name: string): Extract<McpServer, {
14
+ type: "http";
15
+ }>;
16
+ export declare function inspectMcp(dir: string, options?: McpOptions): Promise<McpServerSummary[]>;
@@ -0,0 +1,120 @@
1
+ /** MCP declarations and authorization bindings before an agent is opened. */
2
+ import { readFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import * as Schema from "effect/Schema";
5
+ import { McpError } from "../mcp-api.js";
6
+ import { resolveSecretsDir, resolveStateRoot } from "../paths.js";
7
+ import { agentValues, prepareSecretsDirectory, unprotectedSecretsNote } from "../value-file.js";
8
+ import { log } from "../log.js";
9
+ import { withLockedFile } from "../locked-file.js";
10
+ import { checkedGrant, mcpGrantFile } from "./grants.js";
11
+ import { readMcpConfiguration, checkMcpUrl } from "./config.js";
12
+ const Bindings = Schema.Record(Schema.String, Schema.String);
13
+ function decodeBindings(raw) {
14
+ if (raw === undefined)
15
+ return {};
16
+ try {
17
+ return Schema.decodeUnknownSync(Schema.fromJsonString(Bindings))(raw);
18
+ }
19
+ catch {
20
+ throw new McpError("storage", "Invalid MCP bindings file; refusing to overwrite it");
21
+ }
22
+ }
23
+ export function mcpAuthorization(dir, env, stateRoot, options = {}) {
24
+ if ((options.store === undefined) !== (options.bindings === undefined))
25
+ throw new McpError("configuration", "MCP store and bindings must be supplied together");
26
+ if (options.store && options.bindings)
27
+ return { store: options.store, bindings: { ...options.bindings } };
28
+ const path = join(stateRoot, "mcp-bindings.json");
29
+ let raw;
30
+ try {
31
+ raw = readFileSync(path, "utf8");
32
+ }
33
+ catch (error) {
34
+ if (error.code !== "ENOENT")
35
+ throw error;
36
+ }
37
+ const secretsDir = resolveSecretsDir(dir, env);
38
+ const file = mcpGrantFile(join(secretsDir, "mcp-auth.json"));
39
+ const protectedWrite = async (write) => {
40
+ const unprotected = await prepareSecretsDirectory(dir, secretsDir);
41
+ await write();
42
+ if (unprotected !== undefined)
43
+ log.warn(`[fastagent] ${unprotectedSecretsNote(unprotected)}`);
44
+ };
45
+ return {
46
+ store: {
47
+ ...file,
48
+ withGrant: (id, operation) => file.withGrant(id, (access) => operation({
49
+ ...access,
50
+ write: (record) => protectedWrite(() => access.write(record)),
51
+ remove: () => protectedWrite(() => access.remove()),
52
+ })),
53
+ },
54
+ bindings: decodeBindings(raw),
55
+ };
56
+ }
57
+ export async function writeMcpBinding(stateRoot, server, id) {
58
+ await withLockedFile(join(stateRoot, "mcp-bindings.json"), async (raw) => {
59
+ const entries = decodeBindings(raw);
60
+ if (id === undefined)
61
+ delete entries[server];
62
+ else
63
+ entries[server] = id;
64
+ return { result: undefined, next: `${JSON.stringify(entries)}\n` };
65
+ }, { create: false });
66
+ }
67
+ export function mcpContext(dir, options = {}) {
68
+ const agentDir = resolve(dir);
69
+ const environment = { ...process.env, ...agentValues(agentDir) };
70
+ const stateRoot = resolveStateRoot(agentDir, environment);
71
+ return {
72
+ agentDir,
73
+ environment,
74
+ stateRoot,
75
+ auth: mcpAuthorization(agentDir, environment, stateRoot, options),
76
+ };
77
+ }
78
+ export function httpServer(servers, name) {
79
+ const server = servers.find((s) => s.name === name);
80
+ if (server?.type !== "http")
81
+ throw new McpError("configuration", "OAuth needs a declared HTTP MCP server", name);
82
+ if (new Headers(server.headers).has("authorization"))
83
+ throw new McpError("configuration", "An MCP server cannot use a static Authorization header and an OAuth binding together", name);
84
+ return server;
85
+ }
86
+ export async function inspectMcp(dir, options = {}) {
87
+ const { agentDir, auth } = mcpContext(dir, options);
88
+ return Promise.all(Object.entries(readMcpConfiguration(agentDir).mcpServers).map(async ([name, declaration]) => {
89
+ const server = { ...declaration, name, type: declaration.type ?? "stdio" };
90
+ const id = Object.hasOwn(auth.bindings, server.name) ? auth.bindings[server.name] : undefined;
91
+ const raw = id === undefined ? undefined : await auth.store.read(id);
92
+ const grant = raw === undefined ? undefined : checkedGrant(raw);
93
+ let authorization = "not_bound";
94
+ if (declaration.type !== "http")
95
+ authorization = id === undefined ? "not_required" : "binding_mismatch";
96
+ else if (grant) {
97
+ authorization =
98
+ grant.serverUrl !== checkMcpUrl(declaration.url).href || new Headers(declaration.headers).has("authorization")
99
+ ? "binding_mismatch"
100
+ : grant.status !== "ready"
101
+ ? "reauthorization_required"
102
+ : grant.expiresAt !== undefined && grant.expiresAt <= Date.now() + 30_000
103
+ ? grant.refreshToken
104
+ ? "refresh_needed"
105
+ : "reauthorization_required"
106
+ : "ready";
107
+ }
108
+ else if (id !== undefined)
109
+ authorization = "reauthorization_required";
110
+ else if (declaration.type === "http" && new Headers(declaration.headers).has("authorization"))
111
+ authorization = "not_required";
112
+ return {
113
+ name: server.name,
114
+ transport: server.type,
115
+ ...(declaration.type === "http" ? { url: checkMcpUrl(declaration.url).href } : {}),
116
+ authorization,
117
+ ...(id === undefined ? {} : { grantId: id }),
118
+ };
119
+ }));
120
+ }
@@ -0,0 +1,12 @@
1
+ export declare function checkMcpTarget(serverUrl: string, value: string): URL;
2
+ export declare function mcpFetch(serverUrl: string, fetchImpl: typeof globalThis.fetch, timeoutMs?: number): typeof globalThis.fetch;
3
+ type Lookup = (host: string) => Promise<readonly {
4
+ address: string;
5
+ family: number;
6
+ }[]>;
7
+ export interface McpNetwork {
8
+ fetch: typeof globalThis.fetch;
9
+ close(): Promise<void>;
10
+ }
11
+ export declare function openMcpNetwork(serverUrl: string, customFetch?: typeof globalThis.fetch, resolve?: Lookup): Promise<McpNetwork>;
12
+ export {};