@akira-tl/forgerelay 1.0.1 → 1.1.1
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/CHANGELOG.md +36 -0
- package/README.md +4 -0
- package/capabilities/host-integration/external-mcp/GUIDE.md +29 -0
- package/capabilities/lifecycle-hooks/GUIDE.md +13 -3
- package/dist/activity/runtime/mcp-query-tools.js +16 -10
- package/dist/cli/mcp/external-mcp.js +481 -0
- package/dist/cli/mcp/status.js +295 -0
- package/dist/cli.js +14 -1
- package/dist/lsp/test-support/server-fixture.js +1 -2
- package/dist/mcp/filesystem/filesystem-tools.js +7 -3
- package/dist/mcp/hooks/command-runner.js +12 -7
- package/dist/mcp/hooks/external-mcp-transform.js +224 -0
- package/dist/mcp/hooks/hook-cli.js +3 -0
- package/dist/mcp/hooks/hooks.js +49 -3
- package/dist/mcp/oauth/auth-protocol.js +375 -0
- package/dist/mcp/oauth/oauth-provider.js +11 -12
- package/dist/mcp/oauth/oauth-store.js +2 -2
- package/dist/mcp/oauth/router.js +2 -6
- package/dist/mcp/operations/batch/executor.js +1 -1
- package/dist/mcp/operations/bulk-read.js +7 -6
- package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +387 -0
- package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +59 -0
- package/dist/mcp/operations/external-mcp/external-mcp.js +382 -0
- package/dist/mcp/operations/media-content.js +28 -0
- package/dist/mcp/operations/native-bulk-mutations.js +1 -1
- package/dist/mcp/process/tools.js +19 -15
- package/dist/mcp/request-context.js +18 -0
- package/dist/mcp/request-meta.js +28 -3
- package/dist/mcp/server/core/activity-support.js +2 -2
- package/dist/mcp/server/core/capabilities/external-mcp.js +31 -0
- package/dist/mcp/server/core/capabilities.js +7 -0
- package/dist/mcp/server/core/capability-registry.js +2 -0
- package/dist/mcp/server/core/tool-support.js +29 -0
- package/dist/mcp/server/operations/runtime/filesystem-tools.js +91 -48
- package/dist/mcp/server/operations/runtime/operation-runtime.js +18 -11
- package/dist/mcp/server/transport/http-server.js +42 -21
- package/dist/mcp/server/workspace/runtime/workspace-open.js +13 -9
- package/dist/mcp/server/workspace/runtime/workspace-tools.js +29 -25
- package/dist/runtime/config/config.js +4 -0
- package/dist/runtime/config/external-mcp-auth-store.js +225 -0
- package/dist/runtime/config/external-mcp-config.js +146 -0
- package/dist/runtime/config/external-mcp-registry.js +165 -0
- package/dist/runtime/testing/server-fixture.js +10 -5
- package/dist/server.js +19 -10
- package/dist/subagents/sessions/mcp/audit.js +81 -0
- package/dist/ui/.vite/manifest.json +297 -298
- package/dist/ui/activity-panel-app.html +1 -1
- package/dist/ui/assets/activity-panel-app-Bw7ZFX2g.js +114 -0
- package/dist/ui/assets/angular-html-DeUNP12X.js +1 -0
- package/dist/ui/assets/{angular-ts-BearSZDb.js → angular-ts-B3v8FKi_.js} +1 -1
- package/dist/ui/assets/{apl-CYQtr-y6.js → apl-Bs3iQm4P.js} +1 -1
- package/dist/ui/assets/{astro-DDzqKIjK.js → astro-D6GHecBY.js} +1 -1
- package/dist/ui/assets/{blade-DkwEyHvQ.js → blade-BMNbVOHv.js} +1 -1
- package/dist/ui/assets/c-BquuA6bv.js +1 -0
- package/dist/ui/assets/{cobol-xBn5JxHE.js → cobol-BuZ6kfps.js} +1 -1
- package/dist/ui/assets/{coffee-BaXXvoJw.js → coffee-DgC5uBzL.js} +1 -1
- package/dist/ui/assets/cpp-BdvWqCaB.js +1 -0
- package/dist/ui/assets/{crystal-DjOpXVU1.js → crystal-0Q5qBvNI.js} +1 -1
- package/dist/ui/assets/css-Be_T8idh.js +1 -0
- package/dist/ui/assets/{edge-DNaRizoR.js → edge-DaGLdgh_.js} +1 -1
- package/dist/ui/assets/{elixir-CwBMwZmy.js → elixir-CMgiFIzB.js} +1 -1
- package/dist/ui/assets/{elm-gOUD2CW3.js → elm-B-ufMlMC.js} +1 -1
- package/dist/ui/assets/{erb-BcR-fjUp.js → erb-CaGiOZM-.js} +1 -1
- package/dist/ui/assets/{git-rebase-BAQPKHF6.js → git-rebase-CpwUWCzK.js} +1 -1
- package/dist/ui/assets/{glimmer-js-P_wOfSz_.js → glimmer-js-BRxccvnE.js} +1 -1
- package/dist/ui/assets/{glimmer-ts-B44aY1fZ.js → glimmer-ts-CgQbPzlT.js} +1 -1
- package/dist/ui/assets/glsl-D7JmHFr0.js +1 -0
- package/dist/ui/assets/graphql-C8n0eUBK.js +1 -0
- package/dist/ui/assets/{hack-ddU2k0nt.js → hack-lVN7KVQv.js} +1 -1
- package/dist/ui/assets/haml-h18lG0pS.js +1 -0
- package/dist/ui/assets/{handlebars-BpzaUpmz.js → handlebars-lRFjRPfj.js} +1 -1
- package/dist/ui/assets/heavy-payload-Cqx9MOkH.js +290 -0
- package/dist/ui/assets/html-DtILqTE1.js +1 -0
- package/dist/ui/assets/{html-derivative-B2beVdmA.js → html-derivative-CSTSmPup.js} +1 -1
- package/dist/ui/assets/{http-iEskZy8U.js → http-DNI8fsbY.js} +1 -1
- package/dist/ui/assets/{hurl-BuUsLSKv.js → hurl-DgRi60JG.js} +1 -1
- package/dist/ui/assets/java-CW9pZ5bh.js +1 -0
- package/dist/ui/assets/javascript-Cvg2UrWL.js +1 -0
- package/dist/ui/assets/{jinja-CRpkWwrH.js → jinja-CZw_S1FB.js} +1 -1
- package/dist/ui/assets/{jison-BYfSnuHj.js → jison-C56u_FZv.js} +1 -1
- package/dist/ui/assets/json-DW2UTv1q.js +1 -0
- package/dist/ui/assets/jsx-Zj5gobdD.js +1 -0
- package/dist/ui/assets/{julia-B8Rxy7vD.js → julia-Cvcrre-L.js} +1 -1
- package/dist/ui/assets/{just-DzhIWpFP.js → just-8CZ2u6Tj.js} +1 -1
- package/dist/ui/assets/{latex-CUNjUUL-.js → latex-D289AXWG.js} +1 -1
- package/dist/ui/assets/{liquid-hUg7nC61.js → liquid-CFEX3SkZ.js} +1 -1
- package/dist/ui/assets/lua-BKpJ0ybO.js +1 -0
- package/dist/ui/assets/{marko-CQmIkwMF.js → marko-D9maZ0a6.js} +1 -1
- package/dist/ui/assets/{mdc-CB-6dHYa.js → mdc-rM9l0qWR.js} +1 -1
- package/dist/ui/assets/{nginx-CQORo_Bt.js → nginx-C9w6p2C9.js} +1 -1
- package/dist/ui/assets/{nim-BNwBNzdn.js → nim-BRePHk0J.js} +1 -1
- package/dist/ui/assets/{perl-Cnz14fAJ.js → perl-CskTmiPQ.js} +1 -1
- package/dist/ui/assets/{php-YywDo63b.js → php-DCU_FvNI.js} +1 -1
- package/dist/ui/assets/{pug-BwbJp4cM.js → pug-XLaVxTvv.js} +1 -1
- package/dist/ui/assets/{qml-D2DY6dMH.js → qml-CaONKSl-.js} +1 -1
- package/dist/ui/assets/r-BCVR0ZE3.js +1 -0
- package/dist/ui/assets/{razor-pMTG7k41.js → razor-DDnGXxnU.js} +1 -1
- package/dist/ui/assets/regexp-DWGTYYVx.js +1 -0
- package/dist/ui/assets/{rst-CO_ndDm4.js → rst-Dz3GWvQm.js} +1 -1
- package/dist/ui/assets/{ruby-xzRn9gTh.js → ruby-DC3jGtMY.js} +1 -1
- package/dist/ui/assets/{sas-CBdtFKLA.js → sas-7mNpvsjZ.js} +1 -1
- package/dist/ui/assets/scss-uBsoCJVy.js +1 -0
- package/dist/ui/assets/shellscript-BCN9N_Jf.js +1 -0
- package/dist/ui/assets/{shellsession-BaZnm5rZ.js → shellsession-Hsw46VXV.js} +1 -1
- package/dist/ui/assets/{soy-IouG3xdB.js → soy-D6KV69HU.js} +1 -1
- package/dist/ui/assets/sql-CVzVQkJh.js +1 -0
- package/dist/ui/assets/{stata-dgbkIZHP.js → stata-BwUjXNhI.js} +1 -1
- package/dist/ui/assets/{surrealql-CBROnw_p.js → surrealql-CgloWqRa.js} +1 -1
- package/dist/ui/assets/{svelte-6r6BpX4-.js → svelte-DcC3n6IC.js} +1 -1
- package/dist/ui/assets/{templ-BKD_PsbL.js → templ-DOFfnWTJ.js} +1 -1
- package/dist/ui/assets/{tex-CKY1bwHb.js → tex-ln89VPaX.js} +1 -1
- package/dist/ui/assets/{ts-tags-BiYP5N_C.js → ts-tags-CWMzCstH.js} +1 -1
- package/dist/ui/assets/tsx-DgzfiGeK.js +1 -0
- package/dist/ui/assets/{twig-BTe1oPFW.js → twig-Bi2itZL9.js} +1 -1
- package/dist/ui/assets/typescript-CQ24f4zW.js +1 -0
- package/dist/ui/assets/{vue-CT0IiySh.js → vue-BS8jE5u6.js} +1 -1
- package/dist/ui/assets/{vue-html-B-tXcbat.js → vue-html-o38xqhyB.js} +1 -1
- package/dist/ui/assets/{vue-vine-C5ZyDCrs.js → vue-vine-D2NvKv50.js} +1 -1
- package/dist/ui/assets/xml-_44rx9_2.js +1 -0
- package/dist/ui/assets/{xsl-DvDSndNI.js → xsl-B5QWQPoi.js} +1 -1
- package/dist/ui/assets/yaml-CKL2pEWe.js +1 -0
- package/dist/workspaces/relay/auth/remote-auth.js +3 -5
- package/dist/workspaces/relay/result-support.js +19 -0
- package/dist/workspaces/relay/tests/test-support.js +10 -4
- package/dist/workspaces/relay/workspace-relay.js +20 -12
- package/docs/configuration.md +200 -2
- package/docs/debugging.md +8 -5
- package/docs/roadmap.md +64 -3
- package/docs/security.md +18 -0
- package/package.json +12 -4
- package/scripts/debug/accept/bootstrap.mjs +5 -0
- package/scripts/debug/accept/harness.mjs +10 -3
- package/scripts/debug/accept/media.mjs +335 -0
- package/scripts/debug/accept/modern-http.mjs +169 -0
- package/scripts/debug/accept/support.mjs +10 -6
- package/scripts/debug/accept.mjs +12 -1
- package/scripts/debug/relay-accept/support.mjs +40 -0
- package/scripts/debug/relay-accept.mjs +8 -9
- package/scripts/debug/runtime.mjs +4 -1
- package/scripts/debug/runtime.test.mjs +9 -0
- package/scripts/release/parity-sandbox.mjs +35 -0
- package/scripts/release/release-gate.test.mjs +42 -1
- package/scripts/release-parity.mjs +3 -22
- package/scripts/release-proof.mjs +24 -4
- package/scripts/release-proof.test.mjs +12 -4
- package/dist/ui/assets/activity-panel-app-XlqSanXT.js +0 -115
- package/dist/ui/assets/angular-html-tB9EchbM.js +0 -1
- package/dist/ui/assets/c-Dv-N5hjn.js +0 -1
- package/dist/ui/assets/cpp-C3unXX-3.js +0 -1
- package/dist/ui/assets/css-8Xr4kNNH.js +0 -1
- package/dist/ui/assets/glsl-BvCQg9MI.js +0 -1
- package/dist/ui/assets/graphql-C9S90gn_.js +0 -1
- package/dist/ui/assets/haml-Bb1w9rMG.js +0 -1
- package/dist/ui/assets/heavy-payload-CJKqi8Ac.js +0 -290
- package/dist/ui/assets/html-dUOs5pA9.js +0 -1
- package/dist/ui/assets/java-BFZCrORY.js +0 -1
- package/dist/ui/assets/javascript-DaIQ0Zqj.js +0 -1
- package/dist/ui/assets/json-BQC_Yp48.js +0 -1
- package/dist/ui/assets/jsx-Dd-CJRf9.js +0 -1
- package/dist/ui/assets/lua-BoZTiL2I.js +0 -1
- package/dist/ui/assets/r-B5nYsy-M.js +0 -1
- package/dist/ui/assets/regexp-oP0lbks_.js +0 -1
- package/dist/ui/assets/scss-CEqgKQNf.js +0 -1
- package/dist/ui/assets/shellscript-DzJJiWQC.js +0 -1
- package/dist/ui/assets/sql-DC2rdTzV.js +0 -1
- package/dist/ui/assets/tsx-D2OMiPNF.js +0 -1
- package/dist/ui/assets/typescript-BGIABksB.js +0 -1
- package/dist/ui/assets/xml-CfeBNlMP.js +0 -1
- package/dist/ui/assets/yaml-umbj2r1g.js +0 -1
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
+
import * as prompts from "@clack/prompts";
|
|
5
|
+
import { computeScopeUnion, selectClientAuthMethod, } from "@modelcontextprotocol/client";
|
|
6
|
+
import { externalMcpCredentialIdentity, } from "../../runtime/config/external-mcp-auth-store.js";
|
|
7
|
+
import { ExternalMcpInteractiveOAuthProvider, beginExternalMcpInteractiveOAuth, finishExternalMcpInteractiveOAuth, } from "../../mcp/operations/external-mcp/external-mcp-oauth.js";
|
|
8
|
+
import { ExternalMcpError, ExternalMcpGateway, } from "../../mcp/operations/external-mcp/external-mcp.js";
|
|
9
|
+
import { findExternalMcpServerStatus, formatAuth, formatExternalMcpList, inspectExternalMcpStatus, resolveExternalMcpScope, } from "./status.js";
|
|
10
|
+
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1_000;
|
|
11
|
+
export async function runExternalMcpCommand(args, dependencies = {}) {
|
|
12
|
+
const [subcommand, ...rest] = args;
|
|
13
|
+
switch (subcommand) {
|
|
14
|
+
case "list":
|
|
15
|
+
case "ls":
|
|
16
|
+
await runExternalMcpList(parseMcpScopeArgs("list", rest), dependencies);
|
|
17
|
+
return;
|
|
18
|
+
case "test":
|
|
19
|
+
await runExternalMcpTest(parseMcpTargetArgs("test", rest), dependencies);
|
|
20
|
+
return;
|
|
21
|
+
case "auth":
|
|
22
|
+
await runExternalMcpAuth(parseMcpTargetArgs("auth", rest), dependencies);
|
|
23
|
+
return;
|
|
24
|
+
case "logout":
|
|
25
|
+
await runExternalMcpLogout(parseMcpTargetArgs("logout", rest), dependencies);
|
|
26
|
+
return;
|
|
27
|
+
case undefined:
|
|
28
|
+
case "help":
|
|
29
|
+
case "--help":
|
|
30
|
+
case "-h":
|
|
31
|
+
printMcpHelp();
|
|
32
|
+
return;
|
|
33
|
+
default:
|
|
34
|
+
throw new Error(`Unknown mcp command: ${subcommand}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function parseMcpScopeArgs(command, args) {
|
|
38
|
+
let projectRoot;
|
|
39
|
+
let global = false;
|
|
40
|
+
const rest = [];
|
|
41
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
42
|
+
const arg = args[index];
|
|
43
|
+
if (arg === "--project") {
|
|
44
|
+
projectRoot = args[++index];
|
|
45
|
+
if (!projectRoot)
|
|
46
|
+
throw new Error("Missing value for --project.");
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (arg === "--global") {
|
|
50
|
+
global = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (arg.startsWith("-"))
|
|
54
|
+
throw new Error(`Unknown mcp ${command} option: ${arg}`);
|
|
55
|
+
rest.push(arg);
|
|
56
|
+
}
|
|
57
|
+
if (global && projectRoot)
|
|
58
|
+
throw new Error("--global and --project cannot be used together.");
|
|
59
|
+
return {
|
|
60
|
+
...(projectRoot ? { projectRoot } : {}),
|
|
61
|
+
...(global ? { global: true } : {}),
|
|
62
|
+
rest,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function parseMcpTargetArgs(command, args) {
|
|
66
|
+
const parsed = parseMcpScopeArgs(command, args);
|
|
67
|
+
if (parsed.rest.length !== 1) {
|
|
68
|
+
throw new Error(`Usage: forgerelay mcp ${command} <server> [--project <path>|--global]`);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
server: parsed.rest[0],
|
|
72
|
+
...(parsed.projectRoot ? { projectRoot: parsed.projectRoot } : {}),
|
|
73
|
+
...(parsed.global ? { global: true } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
async function runExternalMcpList(options, dependencies) {
|
|
77
|
+
if (options.rest.length > 0) {
|
|
78
|
+
throw new Error("Usage: forgerelay mcp list [--project <path>|--global]");
|
|
79
|
+
}
|
|
80
|
+
const scope = resolveExternalMcpScope(options, dependencies);
|
|
81
|
+
const status = inspectExternalMcpStatus(scope);
|
|
82
|
+
console.log(formatExternalMcpList(status));
|
|
83
|
+
if (status.configIssues > 0) {
|
|
84
|
+
throw new Error("External MCP configuration or credential status contains issues.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function runExternalMcpTest(options, dependencies) {
|
|
88
|
+
const scope = resolveExternalMcpScope(options, dependencies);
|
|
89
|
+
const initialStatus = inspectExternalMcpStatus(scope);
|
|
90
|
+
const serverStatus = findExternalMcpServerStatus(initialStatus, options.server);
|
|
91
|
+
if (!serverStatus)
|
|
92
|
+
throw new Error(`Unknown configured External MCP server: ${options.server}.`);
|
|
93
|
+
if (!serverStatus.enabled) {
|
|
94
|
+
throw new Error(`External MCP ${options.server} is disabled by ${serverStatus.source} configuration.`);
|
|
95
|
+
}
|
|
96
|
+
const config = scope.snapshot.servers[options.server];
|
|
97
|
+
if (!config)
|
|
98
|
+
throw new Error(`Unknown configured External MCP server: ${options.server}.`);
|
|
99
|
+
printTestTarget(scope, serverStatus);
|
|
100
|
+
const gateway = new ExternalMcpGateway(scope.config.mediaMaxBytes, scope.store);
|
|
101
|
+
try {
|
|
102
|
+
const probe = await gateway.probe(scope.snapshot.servers, options.server, undefined, { workspaceRoot: scope.projectRoot, origins: scope.snapshot.origins });
|
|
103
|
+
console.log("Connection: ok");
|
|
104
|
+
console.log(`Protocol: ${probe.protocolEra}` +
|
|
105
|
+
(probe.protocolVersion ? ` · ${probe.protocolVersion}` : ""));
|
|
106
|
+
console.log(`Tools: ${probe.toolCount}${probe.truncated ? "+ · truncated" : ""}`);
|
|
107
|
+
console.log(`${options.server} is ready.`);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (!(error instanceof ExternalMcpError))
|
|
111
|
+
throw error;
|
|
112
|
+
const latestStatus = inspectExternalMcpStatus(scope);
|
|
113
|
+
const latestServer = findExternalMcpServerStatus(latestStatus, options.server) ?? serverStatus;
|
|
114
|
+
if (error.code === "auth_required" || error.code === "reauthorization_required") {
|
|
115
|
+
console.log(`Auth: ${formatAuth(latestServer)}`);
|
|
116
|
+
console.log("Connection: blocked by authentication");
|
|
117
|
+
console.log(`Reason: ${error.message}`);
|
|
118
|
+
console.log(`Next: ${externalMcpAuthCommand(scope, options.server)}`);
|
|
119
|
+
}
|
|
120
|
+
else if (error.code === "tool_discovery_failed") {
|
|
121
|
+
console.log("Connection: ok");
|
|
122
|
+
console.log("Tools: failed");
|
|
123
|
+
console.log(`Reason: ${error.detail ?? "MCP tools/list failed."}`);
|
|
124
|
+
console.log("Next: check the MCP server logs and retry this test.");
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
console.log("Connection: failed");
|
|
128
|
+
console.log(`Reason: ${error.detail ?? "MCP connection or protocol handshake failed."}`);
|
|
129
|
+
console.log("Next: check the server configuration, process/network reachability, and retry this test.");
|
|
130
|
+
}
|
|
131
|
+
throw new Error(`External MCP ${options.server} test failed.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function printTestTarget(scope, server) {
|
|
135
|
+
console.log(`Testing External MCP ${server.name}`);
|
|
136
|
+
if (scope.mode === "project")
|
|
137
|
+
console.log(`Project: ${scope.projectRoot}`);
|
|
138
|
+
console.log(`Source: ${server.source}`);
|
|
139
|
+
console.log(`Transport: ${server.transport}`);
|
|
140
|
+
console.log(`Auth: ${formatAuth(server)}`);
|
|
141
|
+
}
|
|
142
|
+
function externalMcpAuthCommand(scope, server) {
|
|
143
|
+
return scope.mode === "global"
|
|
144
|
+
? `forgerelay mcp auth ${cliArgument(server)} --global`
|
|
145
|
+
: `forgerelay mcp auth ${cliArgument(server)} --project ${cliArgument(scope.projectRoot)}`;
|
|
146
|
+
}
|
|
147
|
+
function cliArgument(value) {
|
|
148
|
+
return /^[A-Za-z0-9_./:\\-]+$/.test(value) ? value : JSON.stringify(value);
|
|
149
|
+
}
|
|
150
|
+
function formatCredentialScope(source, projectRoot) {
|
|
151
|
+
return source === "project" ? `project · ${projectRoot}` : "global";
|
|
152
|
+
}
|
|
153
|
+
async function runExternalMcpAuth(options, dependencies) {
|
|
154
|
+
assertInteractive(dependencies);
|
|
155
|
+
const target = resolveExternalMcpCliTarget(options, dependencies);
|
|
156
|
+
if (hasStaticAuthorizationHeader(target.serverConfig.headers)) {
|
|
157
|
+
throw new Error(`External MCP ${target.server} uses a static Authorization header; remove that header before using OAuth authentication.`);
|
|
158
|
+
}
|
|
159
|
+
console.log(`Authenticating External MCP ${target.server}`);
|
|
160
|
+
console.log(`Source: ${target.source}`);
|
|
161
|
+
console.log(`Transport: ${target.serverConfig.transport}`);
|
|
162
|
+
console.log(`Credential scope: ${formatCredentialScope(target.source, target.projectRoot)}`);
|
|
163
|
+
const identity = externalMcpCredentialIdentity(target.source, target.server, target.projectRoot);
|
|
164
|
+
const existing = target.store.read(identity);
|
|
165
|
+
const receiver = await (dependencies.createLoopbackReceiver ?? createLoopbackReceiver)(target.serverConfig.oauth?.callbackPort);
|
|
166
|
+
let authorizationUrl;
|
|
167
|
+
let authorizationServerPrinted = false;
|
|
168
|
+
let needsPaste = dependencies.headless ?? isHeadlessEnvironment(dependencies.env ?? process.env);
|
|
169
|
+
try {
|
|
170
|
+
const provider = new ExternalMcpInteractiveOAuthProvider({
|
|
171
|
+
serverUrl: target.serverConfig.url,
|
|
172
|
+
redirectUrl: receiver.redirectUrl,
|
|
173
|
+
...(target.serverConfig.oauth?.clientMetadataUrl
|
|
174
|
+
? { clientMetadataUrl: target.serverConfig.oauth.clientMetadataUrl }
|
|
175
|
+
: {}),
|
|
176
|
+
existing,
|
|
177
|
+
onAuthorizationUrl: async (url) => {
|
|
178
|
+
authorizationUrl = new URL(url);
|
|
179
|
+
await dependencies.observeAuthorizationUrl?.(url);
|
|
180
|
+
const authorizationServer = provider.authorizationServerUrl();
|
|
181
|
+
if (authorizationServer && !authorizationServerPrinted) {
|
|
182
|
+
authorizationServerPrinted = true;
|
|
183
|
+
console.log(`Authorization server: ${authorizationServer}`);
|
|
184
|
+
}
|
|
185
|
+
console.log(`Authorization URL: ${url.toString()}`);
|
|
186
|
+
if (needsPaste)
|
|
187
|
+
return;
|
|
188
|
+
const opened = await (dependencies.openBrowser ?? openBrowser)(url);
|
|
189
|
+
if (opened) {
|
|
190
|
+
console.log("Opened the authorization URL in your browser.");
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
needsPaste = true;
|
|
194
|
+
console.log("Could not open a browser automatically; open the authorization URL manually.");
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
await beginExternalMcpInteractiveOAuth(provider, target.serverConfig.url, computeScopeUnion(existing?.tokens?.scope, existing?.reauthorization?.scope));
|
|
199
|
+
if (!authorizationUrl)
|
|
200
|
+
throw new Error("External MCP OAuth did not provide an authorization URL.");
|
|
201
|
+
let callbackUrl;
|
|
202
|
+
if (needsPaste) {
|
|
203
|
+
console.log("After authorization, paste the final callback URL below.");
|
|
204
|
+
callbackUrl = parseCallbackUrl(await promptMaskedCallback(dependencies));
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
try {
|
|
208
|
+
console.log("Waiting for the browser callback...");
|
|
209
|
+
callbackUrl = await receiver.waitForCallback(CALLBACK_TIMEOUT_MS);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
console.log("Browser callback was not received. Paste the final callback URL below.");
|
|
213
|
+
callbackUrl = parseCallbackUrl(await promptMaskedCallback(dependencies));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const staged = await finishExternalMcpInteractiveOAuth(provider, target.serverConfig.url, callbackUrl);
|
|
217
|
+
await target.store.withIdentityLock(identity, () => target.store.replace(identity, target.serverConfig.url, staged));
|
|
218
|
+
console.log(`Granted scopes: ${staged.tokens?.scope?.trim() || "not reported"}`);
|
|
219
|
+
console.log(`Authenticated External MCP ${target.server} (${target.source}).`);
|
|
220
|
+
console.log(`Credential store: ${target.store.filePath}`);
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
await receiver.close().catch(() => undefined);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function runExternalMcpLogout(options, dependencies) {
|
|
227
|
+
const target = resolveExternalMcpCliTarget(options, dependencies);
|
|
228
|
+
console.log(`Logging out External MCP ${target.server}`);
|
|
229
|
+
console.log(`Source: ${target.source}`);
|
|
230
|
+
console.log(`Credential scope: ${formatCredentialScope(target.source, target.projectRoot)}`);
|
|
231
|
+
const identity = externalMcpCredentialIdentity(target.source, target.server, target.projectRoot);
|
|
232
|
+
let existing;
|
|
233
|
+
await target.store.withIdentityLock(identity, async () => {
|
|
234
|
+
existing = target.store.read(identity);
|
|
235
|
+
if (existing)
|
|
236
|
+
await target.store.remove(identity);
|
|
237
|
+
});
|
|
238
|
+
if (!existing) {
|
|
239
|
+
console.log(`No stored OAuth credential for External MCP ${target.server} (${target.source}).`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
let revocation = "unsupported";
|
|
243
|
+
try {
|
|
244
|
+
revocation = await revokeCredential(existing);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
revocation = "failed";
|
|
248
|
+
}
|
|
249
|
+
console.log(`Removed local OAuth credential for External MCP ${target.server} (${target.source}).`);
|
|
250
|
+
if (revocation === "revoked")
|
|
251
|
+
console.log("Remote revocation: succeeded.");
|
|
252
|
+
else if (revocation === "unsupported")
|
|
253
|
+
console.log("Remote revocation: not advertised by the authorization server.");
|
|
254
|
+
else
|
|
255
|
+
console.log("Remote revocation: failed; the local credential was still removed.");
|
|
256
|
+
}
|
|
257
|
+
function resolveExternalMcpCliTarget(options, dependencies) {
|
|
258
|
+
const scope = resolveExternalMcpScope(options, dependencies);
|
|
259
|
+
const serverConfig = scope.snapshot.servers[options.server];
|
|
260
|
+
const source = scope.snapshot.origins[options.server];
|
|
261
|
+
if (!serverConfig || !source) {
|
|
262
|
+
const maskedBy = scope.snapshot.masked[options.server];
|
|
263
|
+
if (maskedBy) {
|
|
264
|
+
throw new Error(`External MCP ${options.server} is disabled by ${maskedBy} configuration.`);
|
|
265
|
+
}
|
|
266
|
+
throw new Error(`Unknown configured External MCP server: ${options.server}.`);
|
|
267
|
+
}
|
|
268
|
+
if (serverConfig.transport !== "streamable-http") {
|
|
269
|
+
throw new Error(`External MCP ${options.server} uses stdio; interactive OAuth is only available for streamable-http servers.`);
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
store: scope.store,
|
|
273
|
+
projectRoot: scope.projectRoot,
|
|
274
|
+
server: options.server,
|
|
275
|
+
source,
|
|
276
|
+
serverConfig,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
async function revokeCredential(record) {
|
|
280
|
+
const metadata = record.discoveryState?.authorizationServerMetadata;
|
|
281
|
+
if (!metadata || !("revocation_endpoint" in metadata) || typeof metadata.revocation_endpoint !== "string") {
|
|
282
|
+
return "unsupported";
|
|
283
|
+
}
|
|
284
|
+
const endpoint = metadata.revocation_endpoint;
|
|
285
|
+
const token = record.tokens?.refresh_token ?? record.tokens?.access_token;
|
|
286
|
+
if (!token || !record.clientInformation)
|
|
287
|
+
return "unsupported";
|
|
288
|
+
const url = new URL(endpoint);
|
|
289
|
+
assertSecureOAuthEndpoint(url);
|
|
290
|
+
const params = new URLSearchParams({
|
|
291
|
+
token,
|
|
292
|
+
token_type_hint: record.tokens?.refresh_token ? "refresh_token" : "access_token",
|
|
293
|
+
});
|
|
294
|
+
const headers = new Headers({
|
|
295
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
296
|
+
accept: "application/json",
|
|
297
|
+
});
|
|
298
|
+
applyClientAuthentication(record.clientInformation, metadata, headers, params);
|
|
299
|
+
const response = await fetch(url, {
|
|
300
|
+
method: "POST",
|
|
301
|
+
headers,
|
|
302
|
+
body: params,
|
|
303
|
+
signal: AbortSignal.timeout(15_000),
|
|
304
|
+
});
|
|
305
|
+
await response.text().catch(() => "");
|
|
306
|
+
if (!response.ok)
|
|
307
|
+
throw new Error(`OAuth revocation failed with HTTP ${response.status}.`);
|
|
308
|
+
return "revoked";
|
|
309
|
+
}
|
|
310
|
+
function applyClientAuthentication(client, metadata, headers, params) {
|
|
311
|
+
const supported = metadata
|
|
312
|
+
&& "revocation_endpoint_auth_methods_supported" in metadata
|
|
313
|
+
&& Array.isArray(metadata.revocation_endpoint_auth_methods_supported)
|
|
314
|
+
? metadata.revocation_endpoint_auth_methods_supported
|
|
315
|
+
: metadata?.token_endpoint_auth_methods_supported ?? [];
|
|
316
|
+
const method = selectClientAuthMethod(client, supported);
|
|
317
|
+
if (method === "client_secret_basic") {
|
|
318
|
+
if (!client.client_secret)
|
|
319
|
+
throw new Error("OAuth revocation requires a client secret.");
|
|
320
|
+
headers.set("authorization", `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`, "utf8").toString("base64")}`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
params.set("client_id", client.client_id);
|
|
324
|
+
if (method === "client_secret_post" && client.client_secret)
|
|
325
|
+
params.set("client_secret", client.client_secret);
|
|
326
|
+
}
|
|
327
|
+
function assertSecureOAuthEndpoint(url) {
|
|
328
|
+
const loopback = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
|
|
329
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
330
|
+
throw new Error("Refusing OAuth revocation over an insecure non-loopback endpoint.");
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function hasStaticAuthorizationHeader(headers) {
|
|
334
|
+
return Object.keys(headers ?? {}).some((name) => name.toLowerCase() === "authorization");
|
|
335
|
+
}
|
|
336
|
+
function assertInteractive(dependencies) {
|
|
337
|
+
const interactive = dependencies.isInteractive ?? (Boolean(input.isTTY) && Boolean(output.isTTY));
|
|
338
|
+
if (!interactive) {
|
|
339
|
+
throw new Error("forgerelay mcp auth requires an interactive terminal for browser/callback authorization.");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async function promptMaskedCallback(dependencies) {
|
|
343
|
+
if (dependencies.promptCallbackUrl)
|
|
344
|
+
return dependencies.promptCallbackUrl();
|
|
345
|
+
const result = await prompts.password({
|
|
346
|
+
message: "Final OAuth callback URL",
|
|
347
|
+
mask: "*",
|
|
348
|
+
validate: (value) => value?.trim() ? undefined : "Paste the final callback URL.",
|
|
349
|
+
});
|
|
350
|
+
if (prompts.isCancel(result))
|
|
351
|
+
throw new Error("External MCP authentication cancelled.");
|
|
352
|
+
return String(result);
|
|
353
|
+
}
|
|
354
|
+
function parseCallbackUrl(value) {
|
|
355
|
+
try {
|
|
356
|
+
return new URL(value.trim());
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
throw new Error("The pasted OAuth callback URL is not a valid URL.");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async function createLoopbackReceiver(port = 0) {
|
|
363
|
+
let resolveCallback;
|
|
364
|
+
let rejectCallback;
|
|
365
|
+
let settled = false;
|
|
366
|
+
let timer;
|
|
367
|
+
const callbackPromise = new Promise((resolvePromise, rejectPromise) => {
|
|
368
|
+
resolveCallback = resolvePromise;
|
|
369
|
+
rejectCallback = rejectPromise;
|
|
370
|
+
});
|
|
371
|
+
const server = createServer((request, response) => {
|
|
372
|
+
if (!request.url || request.method !== "GET") {
|
|
373
|
+
response.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
|
|
374
|
+
response.end("Method not allowed.\n");
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const address = server.address();
|
|
378
|
+
const url = new URL(request.url, `http://127.0.0.1:${address?.port ?? 0}`);
|
|
379
|
+
if (url.pathname !== "/callback") {
|
|
380
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
381
|
+
response.end("Not found.\n");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
response.writeHead(200, {
|
|
385
|
+
"content-type": "text/plain; charset=utf-8",
|
|
386
|
+
"cache-control": "no-store",
|
|
387
|
+
});
|
|
388
|
+
response.end("ForgeRelay received the authorization response. You can return to the terminal.\n");
|
|
389
|
+
if (!settled) {
|
|
390
|
+
settled = true;
|
|
391
|
+
if (timer)
|
|
392
|
+
clearTimeout(timer);
|
|
393
|
+
resolveCallback(url);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
server.on("error", (error) => {
|
|
397
|
+
if (!settled) {
|
|
398
|
+
settled = true;
|
|
399
|
+
if (timer)
|
|
400
|
+
clearTimeout(timer);
|
|
401
|
+
rejectCallback(error);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
405
|
+
server.once("error", rejectListen);
|
|
406
|
+
server.listen(port, "127.0.0.1", () => {
|
|
407
|
+
server.off("error", rejectListen);
|
|
408
|
+
resolveListen();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
const listeningPort = server.address().port;
|
|
412
|
+
return {
|
|
413
|
+
redirectUrl: new URL(`http://127.0.0.1:${listeningPort}/callback`),
|
|
414
|
+
waitForCallback(timeoutMs = CALLBACK_TIMEOUT_MS) {
|
|
415
|
+
if (!settled && !timer) {
|
|
416
|
+
timer = setTimeout(() => {
|
|
417
|
+
if (settled)
|
|
418
|
+
return;
|
|
419
|
+
settled = true;
|
|
420
|
+
rejectCallback(new Error("Timed out waiting for OAuth callback."));
|
|
421
|
+
}, timeoutMs);
|
|
422
|
+
timer.unref?.();
|
|
423
|
+
}
|
|
424
|
+
return callbackPromise;
|
|
425
|
+
},
|
|
426
|
+
async close() {
|
|
427
|
+
if (timer)
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
if (!server.listening)
|
|
430
|
+
return;
|
|
431
|
+
await new Promise((resolveClose) => server.close(() => resolveClose()));
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
async function openBrowser(url) {
|
|
436
|
+
const command = browserCommand(url.toString());
|
|
437
|
+
if (!command)
|
|
438
|
+
return false;
|
|
439
|
+
return new Promise((resolveOpen) => {
|
|
440
|
+
const child = spawn(command.file, command.args, {
|
|
441
|
+
detached: true,
|
|
442
|
+
stdio: "ignore",
|
|
443
|
+
windowsHide: true,
|
|
444
|
+
});
|
|
445
|
+
let resolved = false;
|
|
446
|
+
child.once("spawn", () => {
|
|
447
|
+
resolved = true;
|
|
448
|
+
child.unref();
|
|
449
|
+
resolveOpen(true);
|
|
450
|
+
});
|
|
451
|
+
child.once("error", () => {
|
|
452
|
+
if (!resolved)
|
|
453
|
+
resolveOpen(false);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
function browserCommand(url) {
|
|
458
|
+
if (process.platform === "darwin")
|
|
459
|
+
return { file: "open", args: [url] };
|
|
460
|
+
if (process.platform === "win32")
|
|
461
|
+
return { file: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
462
|
+
return { file: "xdg-open", args: [url] };
|
|
463
|
+
}
|
|
464
|
+
function isHeadlessEnvironment(env) {
|
|
465
|
+
if (env.SSH_CONNECTION || env.SSH_TTY)
|
|
466
|
+
return true;
|
|
467
|
+
if (process.platform !== "linux")
|
|
468
|
+
return false;
|
|
469
|
+
return !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
470
|
+
}
|
|
471
|
+
function printMcpHelp() {
|
|
472
|
+
console.log([
|
|
473
|
+
"ForgeRelay mcp",
|
|
474
|
+
"",
|
|
475
|
+
"Usage:",
|
|
476
|
+
" forgerelay mcp list [--project <path>|--global]",
|
|
477
|
+
" forgerelay mcp test <server> [--project <path>|--global]",
|
|
478
|
+
" forgerelay mcp auth <server> [--project <path>|--global]",
|
|
479
|
+
" forgerelay mcp logout <server> [--project <path>|--global]",
|
|
480
|
+
].join("\n"));
|
|
481
|
+
}
|