@fastagent-sh/fastagent 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/channels/feishu/feishu-api.d.ts +1 -0
- package/dist/channels/feishu/feishu-api.js +4 -0
- package/dist/channels/feishu/register-app.d.ts +2 -0
- package/dist/channels/feishu/register-app.js +1 -1
- package/dist/channels/lark/credentials-page.d.ts +25 -0
- package/dist/channels/lark/credentials-page.js +206 -0
- package/dist/channels/lark/onboard.d.ts +18 -2
- package/dist/channels/lark/onboard.js +36 -6
- package/dist/channels/slack/setup-server.js +75 -30
- package/dist/channels/wait-health.js +1 -1
- package/dist/cli/add-feishu.d.ts +0 -9
- package/dist/cli/add-feishu.js +62 -38
- package/dist/cli/commands/add.js +5 -12
- package/dist/cli/commands/deploy.js +1 -8
- package/dist/cli/commands/info.js +4 -0
- package/dist/cli/commands/mcp.d.ts +12 -0
- package/dist/cli/commands/mcp.js +156 -0
- package/dist/cli/program.js +59 -0
- package/dist/deploy/agentcore/destroy.js +1 -1
- package/dist/deploy/build-context.js +4 -0
- package/dist/deploy/container.js +1 -2
- package/dist/deploy/docker/run.d.ts +16 -3
- package/dist/deploy/docker/run.js +54 -1
- package/dist/deploy/fly/run.d.ts +0 -14
- package/dist/deploy/fly/run.js +4 -5
- package/dist/dev-supervisor.js +3 -2
- package/dist/harnesses/pi-durable/mcp.d.ts +3 -0
- package/dist/harnesses/pi-durable/mcp.js +34 -0
- package/dist/harnesses/pi-durable/open.d.ts +1 -1
- package/dist/harnesses/pi-durable/open.js +12 -3
- package/dist/harnesses/pi-durable/serve.d.ts +4 -0
- package/dist/harnesses/pi-durable/serve.js +47 -17
- package/dist/http/contract.d.ts +3 -3
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/locked-file.d.ts +2 -2
- package/dist/locked-file.js +8 -3
- package/dist/mcp/auth.d.ts +22 -0
- package/dist/mcp/auth.js +180 -0
- package/dist/mcp/client.d.ts +15 -0
- package/dist/mcp/client.js +170 -0
- package/dist/mcp/config.d.ts +34 -0
- package/dist/mcp/config.js +142 -0
- package/dist/mcp/grants.d.ts +4 -0
- package/dist/mcp/grants.js +132 -0
- package/dist/mcp/host.d.ts +16 -0
- package/dist/mcp/host.js +120 -0
- package/dist/mcp/network.d.ts +12 -0
- package/dist/mcp/network.js +150 -0
- package/dist/mcp/sign-in.d.ts +2 -0
- package/dist/mcp/sign-in.js +204 -0
- package/dist/mcp-api.d.ts +93 -0
- package/dist/mcp-api.js +10 -0
- package/dist/setup-page.d.ts +51 -0
- package/dist/setup-page.js +149 -0
- package/dist/value-file.d.ts +1 -0
- package/dist/value-file.js +15 -16
- package/package.json +3 -2
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** MCP-derived URLs stay public unless they address the explicitly declared local endpoint. */
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import { BlockList, isIP } from "node:net";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Exit from "effect/Exit";
|
|
6
|
+
import { port, portError } from "../effect-port.js";
|
|
7
|
+
import { McpError } from "../mcp-api.js";
|
|
8
|
+
import { checkMcpUrl } from "./config.js";
|
|
9
|
+
const blocked = new BlockList();
|
|
10
|
+
for (const [address, prefix] of [
|
|
11
|
+
["0.0.0.0", 8],
|
|
12
|
+
["10.0.0.0", 8],
|
|
13
|
+
["100.64.0.0", 10],
|
|
14
|
+
["127.0.0.0", 8],
|
|
15
|
+
["169.254.0.0", 16],
|
|
16
|
+
["172.16.0.0", 12],
|
|
17
|
+
["192.0.0.0", 24],
|
|
18
|
+
["192.0.2.0", 24],
|
|
19
|
+
["192.88.99.0", 24],
|
|
20
|
+
["192.168.0.0", 16],
|
|
21
|
+
["198.18.0.0", 15],
|
|
22
|
+
["198.51.100.0", 24],
|
|
23
|
+
["203.0.113.0", 24],
|
|
24
|
+
["224.0.0.0", 4],
|
|
25
|
+
["240.0.0.0", 4],
|
|
26
|
+
])
|
|
27
|
+
blocked.addSubnet(address, prefix, "ipv4");
|
|
28
|
+
blocked.addAddress("168.63.129.16", "ipv4");
|
|
29
|
+
for (const [address, prefix] of [
|
|
30
|
+
["2001::", 23],
|
|
31
|
+
["2001:db8::", 32],
|
|
32
|
+
["2002::", 16],
|
|
33
|
+
["3fff::", 20],
|
|
34
|
+
])
|
|
35
|
+
blocked.addSubnet(address, prefix, "ipv6");
|
|
36
|
+
const globalV6 = new BlockList();
|
|
37
|
+
globalV6.addSubnet("2000::", 3, "ipv6");
|
|
38
|
+
const mappedV4 = new BlockList();
|
|
39
|
+
mappedV4.addSubnet("0.0.0.0", 0, "ipv4");
|
|
40
|
+
const loopback = new BlockList();
|
|
41
|
+
loopback.addSubnet("127.0.0.0", 8, "ipv4");
|
|
42
|
+
loopback.addAddress("::1", "ipv6");
|
|
43
|
+
const hostname = (url) => url.hostname.replace(/^\[|\]$/g, "");
|
|
44
|
+
const localName = (name) => /(^|\.)localhost\.?$/i.test(name);
|
|
45
|
+
function allowedAddress(declared, target, address) {
|
|
46
|
+
const version = isIP(address);
|
|
47
|
+
if (!version)
|
|
48
|
+
return false;
|
|
49
|
+
const family = version === 4 ? "ipv4" : "ipv6";
|
|
50
|
+
if (declared.origin === target.origin &&
|
|
51
|
+
(hostname(declared) === address || (localName(hostname(declared)) && loopback.check(address, family))))
|
|
52
|
+
return true;
|
|
53
|
+
return (!blocked.check(address, family) &&
|
|
54
|
+
(version === 4 || mappedV4.check(address, family) || globalV6.check(address, family)));
|
|
55
|
+
}
|
|
56
|
+
function denied() {
|
|
57
|
+
throw new McpError("authorization_mismatch", "MCP network policy refused an undeclared non-public destination");
|
|
58
|
+
}
|
|
59
|
+
export function checkMcpTarget(serverUrl, value) {
|
|
60
|
+
const declared = checkMcpUrl(serverUrl);
|
|
61
|
+
const target = checkMcpUrl(value);
|
|
62
|
+
const host = hostname(target);
|
|
63
|
+
if (isIP(host) ? !allowedAddress(declared, target, host) : localName(host) && target.origin !== declared.origin)
|
|
64
|
+
denied();
|
|
65
|
+
return target;
|
|
66
|
+
}
|
|
67
|
+
export function mcpFetch(serverUrl, fetchImpl, timeoutMs = 30_000) {
|
|
68
|
+
return (input, init) => {
|
|
69
|
+
checkMcpTarget(serverUrl, input instanceof Request ? input.url : String(input));
|
|
70
|
+
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
|
|
71
|
+
return fetchImpl(input, {
|
|
72
|
+
...init,
|
|
73
|
+
redirect: "error",
|
|
74
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export async function openMcpNetwork(serverUrl, customFetch, resolve = (host) => lookup(host, { all: true, order: "ipv4first" })) {
|
|
79
|
+
if (customFetch)
|
|
80
|
+
return { fetch: mcpFetch(serverUrl, customFetch, 60_000), close: async () => { } };
|
|
81
|
+
// Bun's bare undici shim ignores dispatcher factories; load the installed implementation on both runtimes.
|
|
82
|
+
const { EnvHttpProxyAgent, Pool, fetch } = await import("undici/index.js");
|
|
83
|
+
const { noProxyList } = await import("../proxy.js");
|
|
84
|
+
const declared = checkMcpUrl(serverUrl);
|
|
85
|
+
const pins = new Map();
|
|
86
|
+
let closed = false;
|
|
87
|
+
const dispatcher = new EnvHttpProxyAgent({
|
|
88
|
+
noProxy: noProxyList(),
|
|
89
|
+
proxyTunnel: true,
|
|
90
|
+
factory: (origin, options) => {
|
|
91
|
+
const authority = new URL(origin);
|
|
92
|
+
const target = pins.get(authority.origin);
|
|
93
|
+
if (!target)
|
|
94
|
+
return denied();
|
|
95
|
+
// fetch removes Host; restore the original authority only after it selects this pinned pool.
|
|
96
|
+
return new Pool(target, options).compose((dispatch) => (request, handler) => dispatch({
|
|
97
|
+
...request,
|
|
98
|
+
headers: Array.isArray(request.headers)
|
|
99
|
+
? ["host", authority.host, ...request.headers]
|
|
100
|
+
: { ...request.headers, host: authority.host },
|
|
101
|
+
}, handler));
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const request = async (input, init) => {
|
|
105
|
+
if (closed)
|
|
106
|
+
throw new McpError("connection", "MCP network is closed");
|
|
107
|
+
const request = new Request(input, init);
|
|
108
|
+
const url = checkMcpTarget(serverUrl, request.url);
|
|
109
|
+
if (!pins.has(url.origin)) {
|
|
110
|
+
const resolved = await Effect.runPromiseExit(port(async () => {
|
|
111
|
+
const host = hostname(url);
|
|
112
|
+
const addresses = isIP(host) ? [{ address: host, family: isIP(host) }] : await resolve(host);
|
|
113
|
+
const first = addresses[0];
|
|
114
|
+
if (!first || addresses.some(({ address }) => !allowedAddress(declared, url, address)))
|
|
115
|
+
return denied();
|
|
116
|
+
const pinned = new URL(url.origin);
|
|
117
|
+
const address = first.address;
|
|
118
|
+
pinned.hostname = isIP(address) === 6 ? `[${address}]` : address;
|
|
119
|
+
return pinned;
|
|
120
|
+
}), { signal: request.signal });
|
|
121
|
+
if (Exit.isFailure(resolved)) {
|
|
122
|
+
request.signal.throwIfAborted();
|
|
123
|
+
throw portError(resolved.cause);
|
|
124
|
+
}
|
|
125
|
+
pins.set(url.origin, resolved.value);
|
|
126
|
+
}
|
|
127
|
+
request.signal.throwIfAborted();
|
|
128
|
+
if (closed)
|
|
129
|
+
throw new McpError("connection", "MCP network is closed");
|
|
130
|
+
const headers = new Headers(request.headers);
|
|
131
|
+
if (headers.has("host") && headers.get("host") !== url.host)
|
|
132
|
+
throw new McpError("configuration", "MCP Host header must match the declared URL");
|
|
133
|
+
return (await fetch(request.url, {
|
|
134
|
+
dispatcher,
|
|
135
|
+
method: request.method,
|
|
136
|
+
headers: [...headers],
|
|
137
|
+
signal: request.signal,
|
|
138
|
+
body: request.body,
|
|
139
|
+
duplex: "half",
|
|
140
|
+
redirect: "error",
|
|
141
|
+
}));
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
fetch: mcpFetch(serverUrl, request, 60_000),
|
|
145
|
+
close: async () => {
|
|
146
|
+
closed = true;
|
|
147
|
+
await dispatcher.destroy();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/** Explicit browser OAuth for MCP; no auth URL, code or token passes through a model turn. */
|
|
2
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { agentValues } from "../value-file.js";
|
|
6
|
+
import { resolveMcpServers } from "./config.js";
|
|
7
|
+
import { registerClient, startAuthorization, exchangeAuthorization, selectClientAuthMethod, validateAuthorizationResponseIssuer, } from "@modelcontextprotocol/client";
|
|
8
|
+
import * as Effect from "effect/Effect";
|
|
9
|
+
import * as Deferred from "effect/Deferred";
|
|
10
|
+
import * as Exit from "effect/Exit";
|
|
11
|
+
import { port, portJoin, portCleanup, portError } from "../effect-port.js";
|
|
12
|
+
import { McpError } from "../mcp-api.js";
|
|
13
|
+
import { sendPage, setupRequestUrl, statusCard } from "../setup-page.js";
|
|
14
|
+
import { discoverMcp, mcpFailure } from "./auth.js";
|
|
15
|
+
import { mcpFetch, openMcpNetwork } from "./network.js";
|
|
16
|
+
import { httpServer } from "./host.js";
|
|
17
|
+
import { grantSummary } from "./grants.js";
|
|
18
|
+
export async function signInMcp(request) {
|
|
19
|
+
const timeout = request.timeoutSeconds ?? 900;
|
|
20
|
+
if (!Number.isInteger(timeout) ||
|
|
21
|
+
timeout <= 0 ||
|
|
22
|
+
(request.callbackPort !== undefined &&
|
|
23
|
+
(!Number.isInteger(request.callbackPort) || request.callbackPort < 0 || request.callbackPort > 65535))) {
|
|
24
|
+
throw new McpError("configuration", "Invalid MCP sign-in timeout or callback port", request.server);
|
|
25
|
+
}
|
|
26
|
+
const dir = resolve(request.agentDir);
|
|
27
|
+
const server = httpServer(resolveMcpServers(dir, { ...process.env, ...agentValues(dir), ...request.variables }, request.server), request.server);
|
|
28
|
+
let saved;
|
|
29
|
+
const work = Effect.gen(function* () {
|
|
30
|
+
const network = yield* Effect.acquireRelease(port(() => openMcpNetwork(server.url, request.fetch)), (value) => portCleanup("MCP network", () => value.close()));
|
|
31
|
+
const fetchImpl = mcpFetch(server.url, network.fetch);
|
|
32
|
+
const discovery = yield* port(() => discoverMcp(server.url, fetchImpl));
|
|
33
|
+
if (!discovery.metadata.code_challenge_methods_supported?.includes("S256")) {
|
|
34
|
+
return yield* Effect.fail(new McpError("protocol", "MCP authorization server does not advertise PKCE S256", server.name));
|
|
35
|
+
}
|
|
36
|
+
if (!request.clientId && !discovery.metadata.registration_endpoint)
|
|
37
|
+
return yield* Effect.fail(new McpError("configuration", "This MCP service requires a public clientId or client metadata URL; dynamic registration is unavailable", server.name));
|
|
38
|
+
const scopes = [...new Set(request.scopes ?? discovery.resourceMetadata.scopes_supported ?? [])];
|
|
39
|
+
if (scopes.some((scope) => !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)))
|
|
40
|
+
return yield* Effect.fail(new McpError("configuration", "Invalid OAuth scope", server.name));
|
|
41
|
+
const approved = yield* port(() => request.interaction.confirmAuthorization({
|
|
42
|
+
server: server.name,
|
|
43
|
+
resource: discovery.resource,
|
|
44
|
+
issuer: discovery.issuer,
|
|
45
|
+
scopes,
|
|
46
|
+
}));
|
|
47
|
+
if (!approved)
|
|
48
|
+
return yield* Effect.fail(new McpError("canceled", "MCP authorization canceled", server.name));
|
|
49
|
+
const callback = yield* Deferred.make();
|
|
50
|
+
const state = randomBytes(32).toString("base64url");
|
|
51
|
+
const path = request.clientId ? "/mcp/oauth/callback" : `/mcp/oauth/${randomBytes(24).toString("hex")}`;
|
|
52
|
+
let portNumber = 0;
|
|
53
|
+
let consumed = false;
|
|
54
|
+
const listener = createServer((incoming, response) => {
|
|
55
|
+
// This one-shot listener closes after sign-in; a later flow may bind the same callback port.
|
|
56
|
+
response.setHeader("connection", "close");
|
|
57
|
+
const url = setupRequestUrl(incoming, response);
|
|
58
|
+
if (!url)
|
|
59
|
+
return;
|
|
60
|
+
if (incoming.headers.host !== `127.0.0.1:${portNumber}` ||
|
|
61
|
+
incoming.method !== "GET" ||
|
|
62
|
+
url.pathname !== path ||
|
|
63
|
+
consumed) {
|
|
64
|
+
response.writeHead(404).end("not found\n");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const received = url.searchParams.get("state") ?? "";
|
|
68
|
+
if (Buffer.byteLength(received) !== Buffer.byteLength(state) ||
|
|
69
|
+
!timingSafeEqual(Buffer.from(received), Buffer.from(state))) {
|
|
70
|
+
response.writeHead(400).end("invalid OAuth state\n");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let problem;
|
|
74
|
+
try {
|
|
75
|
+
validateAuthorizationResponseIssuer({
|
|
76
|
+
iss: url.searchParams.get("iss") ?? undefined,
|
|
77
|
+
expectedIssuer: discovery.issuer,
|
|
78
|
+
issParameterSupported: discovery.metadata.authorization_response_iss_parameter_supported === true,
|
|
79
|
+
});
|
|
80
|
+
if (url.searchParams.has("error"))
|
|
81
|
+
problem = new McpError("canceled", "MCP authorization was not approved", server.name);
|
|
82
|
+
else if (!url.searchParams.get("code"))
|
|
83
|
+
problem = new McpError("protocol", "MCP callback contained no authorization code", server.name);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
problem = new McpError("authorization_mismatch", "MCP OAuth callback issuer mismatch", server.name);
|
|
87
|
+
}
|
|
88
|
+
consumed = true;
|
|
89
|
+
const completed = problem ? Deferred.fail(callback, problem) : Deferred.succeed(callback, url.searchParams);
|
|
90
|
+
// Closing the scope tears down the listener; first flush the page or observe the browser disconnect.
|
|
91
|
+
if (response.destroyed)
|
|
92
|
+
Effect.runSync(completed);
|
|
93
|
+
else
|
|
94
|
+
response.once("close", () => Effect.runSync(completed));
|
|
95
|
+
sendPage(response, problem ? 400 : 200, {
|
|
96
|
+
title: "MCP authorization — FastAgent",
|
|
97
|
+
card: statusCard({
|
|
98
|
+
state: problem ? "failed" : "working",
|
|
99
|
+
heading: problem ? "Authorization stopped" : "Authorization response received",
|
|
100
|
+
lede: problem?.message ?? "FastAgent is verifying and saving the authorization.",
|
|
101
|
+
next: "Return to your application or coding agent. Do not copy this address.",
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
yield* Effect.acquireRelease(Effect.succeed(listener), (value) => portCleanup("MCP OAuth callback", () => new Promise((resolve, reject) => {
|
|
106
|
+
value.close((error) => (error ? reject(error) : resolve()));
|
|
107
|
+
value.closeAllConnections();
|
|
108
|
+
})));
|
|
109
|
+
yield* portJoin(() => new Promise((resolve, reject) => {
|
|
110
|
+
listener.once("error", reject);
|
|
111
|
+
listener.listen(request.callbackPort ?? 0, "127.0.0.1", () => {
|
|
112
|
+
listener.off("error", reject);
|
|
113
|
+
resolve();
|
|
114
|
+
});
|
|
115
|
+
}));
|
|
116
|
+
const address = listener.address();
|
|
117
|
+
if (!address || typeof address === "string")
|
|
118
|
+
return yield* Effect.fail(new McpError("connection", "MCP callback has no local port", server.name));
|
|
119
|
+
portNumber = address.port;
|
|
120
|
+
const redirectUrl = `http://127.0.0.1:${portNumber}${path}`;
|
|
121
|
+
const registration = request.clientId
|
|
122
|
+
? { client_id: request.clientId }
|
|
123
|
+
: yield* portJoin(() => registerClient(discovery.issuer, {
|
|
124
|
+
metadata: discovery.metadata,
|
|
125
|
+
clientMetadata: {
|
|
126
|
+
client_name: "FastAgent",
|
|
127
|
+
redirect_uris: [redirectUrl],
|
|
128
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
129
|
+
response_types: ["code"],
|
|
130
|
+
token_endpoint_auth_method: "none",
|
|
131
|
+
application_type: "native",
|
|
132
|
+
scope: scopes.join(" "),
|
|
133
|
+
},
|
|
134
|
+
scope: scopes.join(" "),
|
|
135
|
+
fetchFn: fetchImpl,
|
|
136
|
+
}));
|
|
137
|
+
const { authorizationUrl, codeVerifier } = yield* portJoin(() => startAuthorization(discovery.issuer, {
|
|
138
|
+
metadata: discovery.metadata,
|
|
139
|
+
clientInformation: registration,
|
|
140
|
+
state,
|
|
141
|
+
redirectUrl,
|
|
142
|
+
scope: scopes.join(" "),
|
|
143
|
+
resource: discovery.resource,
|
|
144
|
+
}));
|
|
145
|
+
request.interaction.notify({ type: "auth_url", url: authorizationUrl.href });
|
|
146
|
+
const params = yield* Deferred.await(callback);
|
|
147
|
+
// Redeeming a code can consume it. Finish the bounded exchange and durable save even if its waiter cancels.
|
|
148
|
+
return yield* Effect.uninterruptible(Effect.gen(function* () {
|
|
149
|
+
request.interaction.notify({ type: "progress", message: "Saving MCP authorization" });
|
|
150
|
+
const tokens = yield* portJoin(() => exchangeAuthorization(discovery.issuer, {
|
|
151
|
+
metadata: discovery.metadata,
|
|
152
|
+
clientInformation: registration,
|
|
153
|
+
authorizationCode: params.get("code"),
|
|
154
|
+
codeVerifier,
|
|
155
|
+
redirectUri: redirectUrl,
|
|
156
|
+
iss: params.get("iss") ?? undefined,
|
|
157
|
+
resource: discovery.resource,
|
|
158
|
+
fetchFn: fetchImpl,
|
|
159
|
+
}));
|
|
160
|
+
const granted = tokens.scope === undefined ? scopes : tokens.scope.split(/\s+/).filter(Boolean);
|
|
161
|
+
if (tokens.token_type.toLowerCase() !== "bearer" || granted.some((scope) => !scopes.includes(scope)))
|
|
162
|
+
return yield* Effect.fail(new McpError("insufficient_scope", "MCP authorization returned an unsupported token type or unapproved scopes", server.name));
|
|
163
|
+
const method = selectClientAuthMethod(registration, discovery.metadata.token_endpoint_auth_methods_supported ?? []);
|
|
164
|
+
if (method !== "none" && method !== "client_secret_post" && method !== "client_secret_basic")
|
|
165
|
+
return yield* Effect.fail(new McpError("protocol", "Unsupported MCP client authentication method", server.name));
|
|
166
|
+
const record = {
|
|
167
|
+
version: 1,
|
|
168
|
+
serverUrl: server.url,
|
|
169
|
+
resource: discovery.resource,
|
|
170
|
+
issuer: discovery.issuer,
|
|
171
|
+
client: {
|
|
172
|
+
id: registration.client_id,
|
|
173
|
+
tokenEndpointAuthMethod: method,
|
|
174
|
+
...("client_secret" in registration && registration.client_secret
|
|
175
|
+
? { secret: registration.client_secret }
|
|
176
|
+
: {}),
|
|
177
|
+
},
|
|
178
|
+
scopes: granted,
|
|
179
|
+
status: "ready",
|
|
180
|
+
generation: 0,
|
|
181
|
+
accessToken: tokens.access_token,
|
|
182
|
+
...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
|
|
183
|
+
...(tokens.expires_in === undefined ? {} : { expiresAt: Date.now() + tokens.expires_in * 1000 }),
|
|
184
|
+
};
|
|
185
|
+
const id = randomUUID();
|
|
186
|
+
yield* portJoin(() => request.store.withGrant(id, async (access) => {
|
|
187
|
+
if (access.read())
|
|
188
|
+
throw new McpError("storage", "MCP grant ID collision");
|
|
189
|
+
await access.write(record);
|
|
190
|
+
}));
|
|
191
|
+
saved = grantSummary(id, record);
|
|
192
|
+
return saved;
|
|
193
|
+
}));
|
|
194
|
+
}).pipe(Effect.timeout(timeout * 1000), Effect.scoped);
|
|
195
|
+
const result = await Effect.runPromiseExit(work, { signal: request.interaction.signal });
|
|
196
|
+
if (saved)
|
|
197
|
+
return saved;
|
|
198
|
+
if (Exit.isFailure(result)) {
|
|
199
|
+
if (Exit.hasInterrupts(result) || request.interaction.signal?.aborted)
|
|
200
|
+
throw new McpError("canceled", "MCP sign-in canceled", request.server);
|
|
201
|
+
throw mcpFailure(portError(result.cause), "MCP sign-in", request.server);
|
|
202
|
+
}
|
|
203
|
+
return result.value;
|
|
204
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** MCP authorization as an embedding host sees it. No SDK or harness types cross this boundary. */
|
|
2
|
+
export type McpErrorCode = "configuration" | "authorization_required" | "authorization_mismatch" | "insufficient_scope" | "refresh_uncertain" | "canceled" | "connection" | "protocol" | "storage";
|
|
3
|
+
export declare class McpError extends Error {
|
|
4
|
+
readonly code: McpErrorCode;
|
|
5
|
+
readonly server?: string;
|
|
6
|
+
constructor(code: McpErrorCode, message: string, server?: string);
|
|
7
|
+
}
|
|
8
|
+
export interface McpGrantRecord {
|
|
9
|
+
version: 1;
|
|
10
|
+
serverUrl: string;
|
|
11
|
+
resource: string;
|
|
12
|
+
issuer: string;
|
|
13
|
+
client: {
|
|
14
|
+
id: string;
|
|
15
|
+
secret?: string;
|
|
16
|
+
tokenEndpointAuthMethod: "none" | "client_secret_post" | "client_secret_basic";
|
|
17
|
+
};
|
|
18
|
+
scopes: readonly string[];
|
|
19
|
+
status: "ready" | "refreshing" | "reauthorization_required";
|
|
20
|
+
generation: number;
|
|
21
|
+
accessToken: string;
|
|
22
|
+
refreshToken?: string;
|
|
23
|
+
expiresAt?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface McpGrantSummary {
|
|
26
|
+
id: string;
|
|
27
|
+
serverUrl: string;
|
|
28
|
+
resource: string;
|
|
29
|
+
issuer: string;
|
|
30
|
+
scopes: readonly string[];
|
|
31
|
+
status: McpGrantRecord["status"];
|
|
32
|
+
expiresAt?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface McpGrantAccess {
|
|
35
|
+
read(): McpGrantRecord | undefined;
|
|
36
|
+
write(record: McpGrantRecord): Promise<void>;
|
|
37
|
+
remove(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export interface McpGrantStore {
|
|
40
|
+
read(grantId: string): Promise<McpGrantRecord | undefined>;
|
|
41
|
+
list(): Promise<readonly McpGrantSummary[]>;
|
|
42
|
+
/** Exclusive across all callers sharing storage. Each write is durable immediately, even if operation later throws. */
|
|
43
|
+
withGrant<T>(grantId: string, operation: (grant: McpGrantAccess) => Promise<T>): Promise<T>;
|
|
44
|
+
}
|
|
45
|
+
export interface McpAuthorization {
|
|
46
|
+
store: McpGrantStore;
|
|
47
|
+
bindings: Readonly<Record<string, string>>;
|
|
48
|
+
}
|
|
49
|
+
export interface McpOptions {
|
|
50
|
+
store?: McpGrantStore;
|
|
51
|
+
bindings?: Readonly<Record<string, string>>;
|
|
52
|
+
/** Per-instance interpolation values; never copied into process.env. */
|
|
53
|
+
variables?: Readonly<Record<string, string>>;
|
|
54
|
+
/** Trusted transport override: the host must enforce connection-level DNS/IP and redirect safety itself. */
|
|
55
|
+
fetch?: typeof globalThis.fetch;
|
|
56
|
+
}
|
|
57
|
+
export interface McpServerSummary {
|
|
58
|
+
name: string;
|
|
59
|
+
transport: "stdio" | "http";
|
|
60
|
+
url?: string;
|
|
61
|
+
authorization: "not_required" | "not_bound" | "ready" | "refresh_needed" | "reauthorization_required" | "binding_mismatch";
|
|
62
|
+
grantId?: string;
|
|
63
|
+
}
|
|
64
|
+
export interface McpSignInInteraction {
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
confirmAuthorization(request: {
|
|
67
|
+
server: string;
|
|
68
|
+
resource: string;
|
|
69
|
+
issuer: string;
|
|
70
|
+
scopes: readonly string[];
|
|
71
|
+
}): Promise<boolean>;
|
|
72
|
+
notify(event: {
|
|
73
|
+
type: "auth_url";
|
|
74
|
+
url: string;
|
|
75
|
+
} | {
|
|
76
|
+
type: "progress";
|
|
77
|
+
message: string;
|
|
78
|
+
}): void;
|
|
79
|
+
}
|
|
80
|
+
export interface McpSignInRequest {
|
|
81
|
+
agentDir: string;
|
|
82
|
+
server: string;
|
|
83
|
+
store: McpGrantStore;
|
|
84
|
+
interaction: McpSignInInteraction;
|
|
85
|
+
scopes?: readonly string[];
|
|
86
|
+
/** Non-secret registration choices for servers that require a pre-registered public client. */
|
|
87
|
+
clientId?: string;
|
|
88
|
+
/** With clientId, register http://127.0.0.1:<port>/mcp/oauth/callback. */
|
|
89
|
+
callbackPort?: number;
|
|
90
|
+
timeoutSeconds?: number;
|
|
91
|
+
variables?: Readonly<Record<string, string>>;
|
|
92
|
+
fetch?: McpOptions["fetch"];
|
|
93
|
+
}
|
package/dist/mcp-api.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export declare function setupRequestUrl(request: IncomingMessage, response: ServerResponse): URL | undefined;
|
|
3
|
+
/** `1 Title`: the number and the title on one line, so the step's content below shares the card's one left edge. */
|
|
4
|
+
export declare function step(number: number, title: string, content: string, options?: {
|
|
5
|
+
bare?: boolean;
|
|
6
|
+
}): string;
|
|
7
|
+
/** A sentence under a step's title. `html` is trusted markup: use `<b>` for a name the person must find on another site. */
|
|
8
|
+
export declare const prose: (html: string) => string;
|
|
9
|
+
/** A button that opens another site in a new tab (secondary: the step's real action is the primary button below). */
|
|
10
|
+
export declare function openButton(label: string, href: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* A labelled input. `label` is the name the other site gives the value, so the person never has to map one to the other.
|
|
13
|
+
* A secret gets a Show/Hide toggle; nothing is ever put back in a secret field after a refusal.
|
|
14
|
+
*/
|
|
15
|
+
export declare function field(input: {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
secret?: boolean;
|
|
20
|
+
value?: string;
|
|
21
|
+
invalid?: boolean;
|
|
22
|
+
autofocus?: boolean;
|
|
23
|
+
}): string;
|
|
24
|
+
/**
|
|
25
|
+
* What was refused, in bold, and what to do about it. Sits above the field it is about. `why` is the other side's own
|
|
26
|
+
* words, for someone who has to look it up: small and set apart, so the sentence the person reads stays one they can act on.
|
|
27
|
+
*/
|
|
28
|
+
export declare function refusal(headline: string, detail?: string, why?: string): string;
|
|
29
|
+
export declare const submit: (label: string) => string;
|
|
30
|
+
/** The promise about where a pasted value goes. Said once, the same way, on every page that asks for one. */
|
|
31
|
+
export declare const privacy: () => string;
|
|
32
|
+
/** A page that asks for something: a heading, then the steps, in one form posting back to this address. */
|
|
33
|
+
export declare function formCard(heading: string, steps: string): string;
|
|
34
|
+
/** A page that says where things stand: working (it moves on by itself), done, or stopped. */
|
|
35
|
+
export declare function statusCard(input: {
|
|
36
|
+
state: "working" | "done" | "failed";
|
|
37
|
+
heading: string;
|
|
38
|
+
lede?: string;
|
|
39
|
+
/** What the person does now, set apart under the status. */
|
|
40
|
+
next?: string;
|
|
41
|
+
}): string;
|
|
42
|
+
/**
|
|
43
|
+
* Send a page. The headers are those of a page that takes a secret: nothing cached, nothing framed, nothing loaded from
|
|
44
|
+
* elsewhere (a nonce admits only this page's own style and script). No `form-action`: browsers apply it to the redirects a
|
|
45
|
+
* submission is answered with, and Slack's page answers one by sending the browser on to Slack.
|
|
46
|
+
*/
|
|
47
|
+
export declare function sendPage(response: ServerResponse, status: number, page: {
|
|
48
|
+
title: string;
|
|
49
|
+
card: string;
|
|
50
|
+
head?: string;
|
|
51
|
+
}): void;
|