@akira-tl/forgerelay 1.1.0 → 1.1.2
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 +26 -0
- package/README.md +4 -2
- 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/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/external-mcp/external-mcp-oauth.js +387 -0
- package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +23 -6
- package/dist/mcp/operations/external-mcp/external-mcp.js +178 -23
- 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/capabilities.js +1 -4
- package/dist/mcp/server/operations/runtime/filesystem-tools.js +35 -28
- package/dist/mcp/server/operations/runtime/operation-runtime.js +7 -7
- package/dist/mcp/server/transport/http-server.js +41 -20
- package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +2 -0
- package/dist/mcp/server/workspace/runtime/workspace-open.js +13 -9
- package/dist/mcp/server/workspace/runtime/workspace-tools.js +31 -27
- package/dist/runtime/config/external-mcp-auth-store.js +225 -0
- package/dist/runtime/config/external-mcp-config.js +76 -22
- package/dist/runtime/config/external-mcp-registry.js +165 -0
- package/dist/runtime/testing/server-fixture.js +4 -4
- package/dist/server.js +15 -9
- 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/tests/test-support.js +7 -4
- package/dist/workspaces/relay/workspace-relay.js +14 -8
- package/dist/workspaces/resources/skills.js +1 -1
- package/docs/chatgpt-coding-workflow.md +12 -6
- package/docs/configuration.md +158 -6
- package/docs/debugging.md +8 -5
- package/docs/roadmap.md +64 -3
- package/docs/security.md +18 -0
- package/package.json +11 -4
- package/scripts/debug/accept/bootstrap.mjs +2 -0
- package/scripts/debug/accept/harness.mjs +9 -2
- package/scripts/debug/accept/modern-http.mjs +169 -0
- package/scripts/debug/accept/support.mjs +10 -6
- package/scripts/debug/accept.mjs +1 -0
- 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,387 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { AuthorizationServerMismatchError, InsufficientScopeError, OAuthError, OAuthErrorCode, SdkHttpError, UnauthorizedError, auth, computeScopeUnion, extractWWWAuthenticateParams, LATEST_PROTOCOL_VERSION, refreshAuthorization, resourceUrlFromServerUrl, } from "@modelcontextprotocol/client";
|
|
3
|
+
export class ExternalMcpOAuthError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = "ExternalMcpOAuthError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const CLIENT_NAME = "ForgeRelay External MCP";
|
|
12
|
+
const CLIENT_URI = "https://github.com/Akira-TL/forgerelay";
|
|
13
|
+
export function createExternalMcpRuntimeAuth(store, identity, serverUrl) {
|
|
14
|
+
const record = store.read(identity);
|
|
15
|
+
if (!record)
|
|
16
|
+
return { identity, serverUrl, bindingMismatch: false };
|
|
17
|
+
if (!record.tokens?.access_token
|
|
18
|
+
&& record.reauthorization?.reason === "authorization_required"
|
|
19
|
+
&& sameServerUrl(record.serverUrl, serverUrl)) {
|
|
20
|
+
return { identity, serverUrl, bindingMismatch: false };
|
|
21
|
+
}
|
|
22
|
+
if (!sameServerUrl(record.serverUrl, serverUrl)) {
|
|
23
|
+
return { identity, serverUrl, bindingMismatch: true };
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
assertStoredBinding(record);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof AuthorizationServerMismatchError) {
|
|
30
|
+
return { identity, serverUrl, bindingMismatch: true };
|
|
31
|
+
}
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
const state = { revision: record.revision };
|
|
35
|
+
const authProvider = {
|
|
36
|
+
token: async () => {
|
|
37
|
+
const latest = store.read(identity);
|
|
38
|
+
if (!latest || !sameServerUrl(latest.serverUrl, serverUrl))
|
|
39
|
+
return undefined;
|
|
40
|
+
state.revision = latest.revision;
|
|
41
|
+
return latest.tokens?.access_token;
|
|
42
|
+
},
|
|
43
|
+
onUnauthorized: async () => {
|
|
44
|
+
await store.withIdentityLock(identity, async () => {
|
|
45
|
+
const latest = store.read(identity);
|
|
46
|
+
if (!latest)
|
|
47
|
+
throw authRequired(identity.server);
|
|
48
|
+
if (!sameServerUrl(latest.serverUrl, serverUrl)) {
|
|
49
|
+
await markReauthorization(store, identity, latest, "binding_changed");
|
|
50
|
+
throw reauthorizationRequired(identity.server, "OAuth resource binding changed.");
|
|
51
|
+
}
|
|
52
|
+
if (latest.revision !== state.revision) {
|
|
53
|
+
state.revision = latest.revision;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!latest.tokens?.refresh_token || !latest.clientInformation) {
|
|
57
|
+
await markReauthorization(store, identity, latest, "authorization_required");
|
|
58
|
+
throw reauthorizationRequired(identity.server, "Stored OAuth credentials cannot be refreshed.");
|
|
59
|
+
}
|
|
60
|
+
assertStoredBinding(latest);
|
|
61
|
+
const authorizationServerUrl = latest.discoveryState?.authorizationServerUrl
|
|
62
|
+
?? latest.authorizationServerUrl;
|
|
63
|
+
if (!authorizationServerUrl) {
|
|
64
|
+
await markReauthorization(store, identity, latest, "binding_changed");
|
|
65
|
+
throw reauthorizationRequired(identity.server, "Stored OAuth authorization-server binding is incomplete.");
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const refreshed = await refreshAuthorization(authorizationServerUrl, {
|
|
69
|
+
metadata: latest.discoveryState?.authorizationServerMetadata,
|
|
70
|
+
clientInformation: latest.clientInformation,
|
|
71
|
+
refreshToken: latest.tokens.refresh_token,
|
|
72
|
+
resource: latest.resourceUrl ? new URL(latest.resourceUrl) : resourceUrlFromServerUrl(serverUrl),
|
|
73
|
+
});
|
|
74
|
+
const expectedIssuer = storedIssuer(latest, authorizationServerUrl);
|
|
75
|
+
const next = await store.replace(identity, serverUrl, {
|
|
76
|
+
tokens: { ...refreshed, issuer: expectedIssuer },
|
|
77
|
+
clientInformation: latest.clientInformation,
|
|
78
|
+
...(latest.discoveryState ? { discoveryState: latest.discoveryState } : {}),
|
|
79
|
+
authorizationServerUrl: expectedIssuer,
|
|
80
|
+
...(latest.resourceUrl ? { resourceUrl: latest.resourceUrl } : {}),
|
|
81
|
+
});
|
|
82
|
+
state.revision = next.revision;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (isCredentialReauthorizationError(error)) {
|
|
86
|
+
await markReauthorization(store, identity, latest, "invalid_grant");
|
|
87
|
+
throw reauthorizationRequired(identity.server, "Stored OAuth credentials require fresh authorization.");
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
return { authProvider, bindingMismatch: false, identity, serverUrl };
|
|
95
|
+
}
|
|
96
|
+
export async function markExternalMcpAuthorizationRequired(store, runtimeAuth) {
|
|
97
|
+
if (!runtimeAuth)
|
|
98
|
+
return;
|
|
99
|
+
await store.withIdentityLock(runtimeAuth.identity, async () => {
|
|
100
|
+
const latest = store.read(runtimeAuth.identity);
|
|
101
|
+
if (latest)
|
|
102
|
+
return;
|
|
103
|
+
await store.replace(runtimeAuth.identity, runtimeAuth.serverUrl, {
|
|
104
|
+
reauthorization: {
|
|
105
|
+
reason: "authorization_required",
|
|
106
|
+
observedAt: new Date().toISOString(),
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export async function markExternalMcpReauthorization(store, runtimeAuth, reason, details = {}) {
|
|
112
|
+
if (!runtimeAuth)
|
|
113
|
+
return;
|
|
114
|
+
await store.withIdentityLock(runtimeAuth.identity, async () => {
|
|
115
|
+
const latest = store.read(runtimeAuth.identity);
|
|
116
|
+
if (!latest)
|
|
117
|
+
return;
|
|
118
|
+
if (reason !== "binding_changed" && !sameServerUrl(latest.serverUrl, runtimeAuth.serverUrl))
|
|
119
|
+
return;
|
|
120
|
+
await markReauthorization(store, runtimeAuth.identity, latest, reason, details);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
export function externalMcpAuthError(server, error, runtimeAuth) {
|
|
124
|
+
if (error instanceof ExternalMcpOAuthError)
|
|
125
|
+
return error;
|
|
126
|
+
if (runtimeAuth?.bindingMismatch) {
|
|
127
|
+
return reauthorizationRequired(server, "OAuth resource binding changed.");
|
|
128
|
+
}
|
|
129
|
+
if (error instanceof InsufficientScopeError) {
|
|
130
|
+
return reauthorizationRequired(server, "External MCP requires additional OAuth scope.");
|
|
131
|
+
}
|
|
132
|
+
if (error instanceof AuthorizationServerMismatchError) {
|
|
133
|
+
return reauthorizationRequired(server, "OAuth authorization-server binding changed.");
|
|
134
|
+
}
|
|
135
|
+
if (error instanceof UnauthorizedError || (error instanceof SdkHttpError && error.status === 401)) {
|
|
136
|
+
return runtimeAuth?.authProvider
|
|
137
|
+
? reauthorizationRequired(server, "Stored OAuth credentials were rejected.")
|
|
138
|
+
: authRequired(server);
|
|
139
|
+
}
|
|
140
|
+
if (isCredentialReauthorizationError(error)) {
|
|
141
|
+
return reauthorizationRequired(server, "Stored OAuth credentials require fresh authorization.");
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
export class ExternalMcpInteractiveOAuthProvider {
|
|
146
|
+
options;
|
|
147
|
+
redirectUrl;
|
|
148
|
+
clientMetadataUrl;
|
|
149
|
+
clientMetadata;
|
|
150
|
+
stateValue = randomBytes(32).toString("base64url");
|
|
151
|
+
codeVerifierValue;
|
|
152
|
+
clientInformationValue;
|
|
153
|
+
tokensValue;
|
|
154
|
+
discoveryStateValue;
|
|
155
|
+
authorizationServerUrlValue;
|
|
156
|
+
resourceUrlValue;
|
|
157
|
+
constructor(options) {
|
|
158
|
+
this.options = options;
|
|
159
|
+
this.redirectUrl = options.redirectUrl;
|
|
160
|
+
this.clientMetadataUrl = options.clientMetadataUrl;
|
|
161
|
+
this.clientMetadata = {
|
|
162
|
+
client_name: CLIENT_NAME,
|
|
163
|
+
client_uri: CLIENT_URI,
|
|
164
|
+
redirect_uris: [this.redirectUrl.toString()],
|
|
165
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
166
|
+
response_types: ["code"],
|
|
167
|
+
token_endpoint_auth_method: "none",
|
|
168
|
+
};
|
|
169
|
+
if (options.existing && sameServerUrl(options.existing.serverUrl, options.serverUrl)) {
|
|
170
|
+
this.clientInformationValue = clone(options.existing.clientInformation);
|
|
171
|
+
this.tokensValue = clone(options.existing.tokens);
|
|
172
|
+
this.discoveryStateValue = clone(options.existing.discoveryState);
|
|
173
|
+
this.authorizationServerUrlValue = options.existing.authorizationServerUrl;
|
|
174
|
+
this.resourceUrlValue = options.existing.resourceUrl;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
state() {
|
|
178
|
+
return this.stateValue;
|
|
179
|
+
}
|
|
180
|
+
clientInformation(_ctx) {
|
|
181
|
+
return clone(this.clientInformationValue);
|
|
182
|
+
}
|
|
183
|
+
saveClientInformation(clientInformation) {
|
|
184
|
+
this.clientInformationValue = clone(clientInformation);
|
|
185
|
+
}
|
|
186
|
+
tokens(_ctx) {
|
|
187
|
+
return clone(this.tokensValue);
|
|
188
|
+
}
|
|
189
|
+
saveTokens(tokens) {
|
|
190
|
+
this.tokensValue = clone(tokens);
|
|
191
|
+
}
|
|
192
|
+
redirectToAuthorization(authorizationUrl) {
|
|
193
|
+
return this.options.onAuthorizationUrl(new URL(authorizationUrl));
|
|
194
|
+
}
|
|
195
|
+
saveCodeVerifier(codeVerifier) {
|
|
196
|
+
this.codeVerifierValue = codeVerifier;
|
|
197
|
+
}
|
|
198
|
+
codeVerifier() {
|
|
199
|
+
if (!this.codeVerifierValue)
|
|
200
|
+
throw new Error("OAuth PKCE verifier is not available for this authorization session.");
|
|
201
|
+
return this.codeVerifierValue;
|
|
202
|
+
}
|
|
203
|
+
saveAuthorizationServerUrl(authorizationServerUrl) {
|
|
204
|
+
this.authorizationServerUrlValue = authorizationServerUrl;
|
|
205
|
+
}
|
|
206
|
+
authorizationServerUrl() {
|
|
207
|
+
return this.authorizationServerUrlValue;
|
|
208
|
+
}
|
|
209
|
+
saveResourceUrl(resourceUrl) {
|
|
210
|
+
this.resourceUrlValue = resourceUrl;
|
|
211
|
+
}
|
|
212
|
+
resourceUrl() {
|
|
213
|
+
return this.resourceUrlValue;
|
|
214
|
+
}
|
|
215
|
+
saveDiscoveryState(state) {
|
|
216
|
+
this.discoveryStateValue = clone(state);
|
|
217
|
+
}
|
|
218
|
+
discoveryState() {
|
|
219
|
+
return clone(this.discoveryStateValue);
|
|
220
|
+
}
|
|
221
|
+
invalidateCredentials(scope) {
|
|
222
|
+
if (scope === "all" || scope === "client")
|
|
223
|
+
this.clientInformationValue = undefined;
|
|
224
|
+
if (scope === "all" || scope === "tokens")
|
|
225
|
+
this.tokensValue = undefined;
|
|
226
|
+
if (scope === "all" || scope === "verifier")
|
|
227
|
+
this.codeVerifierValue = undefined;
|
|
228
|
+
if (scope === "all" || scope === "discovery") {
|
|
229
|
+
this.discoveryStateValue = undefined;
|
|
230
|
+
this.authorizationServerUrlValue = undefined;
|
|
231
|
+
this.resourceUrlValue = undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
validateCallback(callbackUrl) {
|
|
235
|
+
if (callbackUrl.origin !== this.redirectUrl.origin || callbackUrl.pathname !== this.redirectUrl.pathname) {
|
|
236
|
+
throw new Error("OAuth callback URL does not match the ForgeRelay redirect URL.");
|
|
237
|
+
}
|
|
238
|
+
const state = callbackUrl.searchParams.get("state");
|
|
239
|
+
if (!state || state !== this.stateValue)
|
|
240
|
+
throw new Error("OAuth callback state did not match the authorization request.");
|
|
241
|
+
const error = callbackUrl.searchParams.get("error");
|
|
242
|
+
if (error)
|
|
243
|
+
throw new Error(`OAuth authorization failed: ${safeOAuthErrorCode(error)}.`);
|
|
244
|
+
const code = callbackUrl.searchParams.get("code");
|
|
245
|
+
if (!code)
|
|
246
|
+
throw new Error("OAuth callback did not contain an authorization code.");
|
|
247
|
+
const iss = callbackUrl.searchParams.get("iss") ?? undefined;
|
|
248
|
+
return { code, ...(iss ? { iss } : {}) };
|
|
249
|
+
}
|
|
250
|
+
snapshot() {
|
|
251
|
+
return {
|
|
252
|
+
...(this.tokensValue ? { tokens: clone(this.tokensValue) } : {}),
|
|
253
|
+
...(this.clientInformationValue ? { clientInformation: clone(this.clientInformationValue) } : {}),
|
|
254
|
+
...(this.discoveryStateValue ? { discoveryState: clone(this.discoveryStateValue) } : {}),
|
|
255
|
+
...(this.authorizationServerUrlValue ? { authorizationServerUrl: this.authorizationServerUrlValue } : {}),
|
|
256
|
+
...(this.resourceUrlValue ? { resourceUrl: this.resourceUrlValue } : {}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
export async function beginExternalMcpInteractiveOAuth(provider, serverUrl, requestedScope) {
|
|
261
|
+
// A manual auth command must rediscover the current authorization server instead of
|
|
262
|
+
// trusting discovery state captured by an older credential for the same MCP URL.
|
|
263
|
+
provider.invalidateCredentials("discovery");
|
|
264
|
+
const challenge = await probeExternalMcpOAuthChallenge(serverUrl);
|
|
265
|
+
const scope = computeScopeUnion(requestedScope, challenge.scope);
|
|
266
|
+
const result = await auth(provider, {
|
|
267
|
+
serverUrl,
|
|
268
|
+
...(challenge.resourceMetadataUrl ? { resourceMetadataUrl: challenge.resourceMetadataUrl } : {}),
|
|
269
|
+
...(scope ? { scope } : {}),
|
|
270
|
+
forceReauthorization: true,
|
|
271
|
+
});
|
|
272
|
+
if (result !== "REDIRECT")
|
|
273
|
+
throw new Error("External MCP OAuth did not start an interactive authorization redirect.");
|
|
274
|
+
}
|
|
275
|
+
async function probeExternalMcpOAuthChallenge(serverUrl) {
|
|
276
|
+
const response = await fetch(serverUrl, {
|
|
277
|
+
method: "POST",
|
|
278
|
+
redirect: "manual",
|
|
279
|
+
headers: {
|
|
280
|
+
accept: "application/json, text/event-stream",
|
|
281
|
+
"content-type": "application/json",
|
|
282
|
+
"mcp-protocol-version": LATEST_PROTOCOL_VERSION,
|
|
283
|
+
},
|
|
284
|
+
body: JSON.stringify({
|
|
285
|
+
jsonrpc: "2.0",
|
|
286
|
+
id: "forgerelay-oauth-probe",
|
|
287
|
+
method: "server/discover",
|
|
288
|
+
params: {
|
|
289
|
+
_meta: {
|
|
290
|
+
"io.modelcontextprotocol/protocolVersion": LATEST_PROTOCOL_VERSION,
|
|
291
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
292
|
+
"io.modelcontextprotocol/clientInfo": { name: "forgerelay", version: "1.0.0" },
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
}),
|
|
296
|
+
signal: AbortSignal.timeout(15_000),
|
|
297
|
+
});
|
|
298
|
+
try {
|
|
299
|
+
if (response.status !== 401)
|
|
300
|
+
return {};
|
|
301
|
+
const challenge = extractWWWAuthenticateParams(response);
|
|
302
|
+
return {
|
|
303
|
+
...(challenge.resourceMetadataUrl ? { resourceMetadataUrl: challenge.resourceMetadataUrl } : {}),
|
|
304
|
+
...(challenge.scope ? { scope: challenge.scope } : {}),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
await response.text().catch(() => "");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
export async function finishExternalMcpInteractiveOAuth(provider, serverUrl, callbackUrl) {
|
|
312
|
+
const callback = provider.validateCallback(callbackUrl);
|
|
313
|
+
const result = await auth(provider, {
|
|
314
|
+
serverUrl,
|
|
315
|
+
authorizationCode: callback.code,
|
|
316
|
+
...(callback.iss ? { iss: callback.iss } : {}),
|
|
317
|
+
});
|
|
318
|
+
if (result !== "AUTHORIZED")
|
|
319
|
+
throw new Error("External MCP OAuth token exchange did not complete.");
|
|
320
|
+
const snapshot = provider.snapshot();
|
|
321
|
+
if (!snapshot.tokens?.access_token || !snapshot.clientInformation) {
|
|
322
|
+
throw new Error("External MCP OAuth completed without usable credentials.");
|
|
323
|
+
}
|
|
324
|
+
return snapshot;
|
|
325
|
+
}
|
|
326
|
+
function assertStoredBinding(record) {
|
|
327
|
+
const authorizationServerUrl = record.discoveryState?.authorizationServerUrl ?? record.authorizationServerUrl;
|
|
328
|
+
if (!authorizationServerUrl)
|
|
329
|
+
return;
|
|
330
|
+
const expectedIssuer = storedIssuer(record, authorizationServerUrl);
|
|
331
|
+
for (const candidate of [record.tokens?.issuer, record.clientInformation?.issuer]) {
|
|
332
|
+
if (candidate && !sameIssuer(candidate, expectedIssuer)) {
|
|
333
|
+
throw new AuthorizationServerMismatchError(candidate, expectedIssuer);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function storedIssuer(record, authorizationServerUrl) {
|
|
338
|
+
return record.discoveryState?.authorizationServerMetadata?.issuer
|
|
339
|
+
?? record.authorizationServerUrl
|
|
340
|
+
?? authorizationServerUrl;
|
|
341
|
+
}
|
|
342
|
+
async function markReauthorization(store, identity, current, reason, details = {}) {
|
|
343
|
+
await store.replace(identity, current.serverUrl, {
|
|
344
|
+
...(current.tokens ? { tokens: current.tokens } : {}),
|
|
345
|
+
...(current.clientInformation ? { clientInformation: current.clientInformation } : {}),
|
|
346
|
+
...(current.discoveryState ? { discoveryState: current.discoveryState } : {}),
|
|
347
|
+
...(current.authorizationServerUrl ? { authorizationServerUrl: current.authorizationServerUrl } : {}),
|
|
348
|
+
...(current.resourceUrl ? { resourceUrl: current.resourceUrl } : {}),
|
|
349
|
+
reauthorization: {
|
|
350
|
+
reason,
|
|
351
|
+
observedAt: new Date().toISOString(),
|
|
352
|
+
...(details.scope?.trim() ? { scope: details.scope.trim() } : {}),
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
function isCredentialReauthorizationError(error) {
|
|
357
|
+
return error instanceof OAuthError && [
|
|
358
|
+
OAuthErrorCode.InvalidGrant,
|
|
359
|
+
OAuthErrorCode.InvalidClient,
|
|
360
|
+
OAuthErrorCode.UnauthorizedClient,
|
|
361
|
+
].includes(error.code);
|
|
362
|
+
}
|
|
363
|
+
function authRequired(server) {
|
|
364
|
+
return new ExternalMcpOAuthError("auth_required", `External MCP ${server} requires OAuth authorization. Run: forgerelay mcp auth ${server}`);
|
|
365
|
+
}
|
|
366
|
+
function reauthorizationRequired(server, reason) {
|
|
367
|
+
return new ExternalMcpOAuthError("reauthorization_required", `${reason} Run: forgerelay mcp auth ${server}`);
|
|
368
|
+
}
|
|
369
|
+
function sameServerUrl(left, right) {
|
|
370
|
+
try {
|
|
371
|
+
return new URL(left).toString() === new URL(right).toString();
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function sameIssuer(left, right) {
|
|
378
|
+
// RFC 9207 issuer validation uses simple string comparison; URL normalization
|
|
379
|
+
// would incorrectly treat distinct issuer identifiers as equivalent.
|
|
380
|
+
return left === right;
|
|
381
|
+
}
|
|
382
|
+
function safeOAuthErrorCode(value) {
|
|
383
|
+
return /^[a-z0-9._-]{1,80}$/i.test(value) ? value : "authorization_error";
|
|
384
|
+
}
|
|
385
|
+
function clone(value) {
|
|
386
|
+
return value === undefined ? undefined : structuredClone(value);
|
|
387
|
+
}
|
|
@@ -1,28 +1,45 @@
|
|
|
1
|
+
import { ExternalMcpConfigRegistry } from "../../../runtime/config/external-mcp-registry.js";
|
|
2
|
+
import { ExternalMcpCredentialStore } from "../../../runtime/config/external-mcp-auth-store.js";
|
|
3
|
+
import { logEvent } from "../../../runtime/logging/logger.js";
|
|
1
4
|
import { CapabilityError } from "../../server/core/capability-registry.js";
|
|
2
5
|
import { requireCapabilityWorkspaceRoot } from "../../server/core/capability-support.js";
|
|
3
6
|
import { ExternalMcpTransformError, ExternalMcpTransformRunner, } from "../../hooks/external-mcp-transform.js";
|
|
4
7
|
import { ExternalMcpError, ExternalMcpGateway } from "./external-mcp.js";
|
|
5
8
|
export function createExternalMcpCapabilityRuntime(config) {
|
|
6
|
-
const
|
|
9
|
+
const credentialStore = new ExternalMcpCredentialStore({ configDir: config.configDir });
|
|
10
|
+
const externalMcp = new ExternalMcpGateway(config.mediaMaxBytes, credentialStore);
|
|
11
|
+
const registry = new ExternalMcpConfigRegistry({
|
|
12
|
+
configDir: config.configDir,
|
|
13
|
+
legacyServers: config.mcpServers,
|
|
14
|
+
onDiagnostic: (diagnostic) => logEvent(config.logging, "warn", "external_mcp_config_invalid", {
|
|
15
|
+
source: diagnostic.source,
|
|
16
|
+
path: diagnostic.path,
|
|
17
|
+
reason: diagnostic.message,
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
7
20
|
const transforms = new ExternalMcpTransformRunner(config.hooks, config.logging, process.env, config.commandShellRuntime, config.mediaMaxBytes);
|
|
8
21
|
return {
|
|
9
|
-
available:
|
|
10
|
-
unavailableReason: externalMcp.available ? undefined : "No external MCP servers are configured.",
|
|
22
|
+
available: true,
|
|
11
23
|
run: async (input, context, runOptions) => {
|
|
12
24
|
try {
|
|
25
|
+
const workspaceRoot = requireCapabilityWorkspaceRoot(context);
|
|
26
|
+
const snapshot = registry.resolve(workspaceRoot);
|
|
13
27
|
const transformContext = (server, tool) => ({
|
|
14
28
|
workspaceId: context.workspaceId,
|
|
15
|
-
workspaceRoot
|
|
29
|
+
workspaceRoot,
|
|
16
30
|
workspaceMode: context.workspaceMode,
|
|
17
31
|
server,
|
|
18
32
|
tool,
|
|
19
33
|
});
|
|
20
|
-
const result = await externalMcp.run(input, runOptions.signal, input.operation === "call"
|
|
34
|
+
const result = await externalMcp.run(snapshot.servers, input, runOptions.signal, input.operation === "call"
|
|
21
35
|
? {
|
|
22
36
|
request: (server, tool, arguments_) => transforms.transformRequest(transformContext(server, tool), arguments_, runOptions.signal),
|
|
23
37
|
result: (server, tool, externalResult) => transforms.transformResult(transformContext(server, tool), externalResult, runOptions.signal),
|
|
24
38
|
}
|
|
25
|
-
: undefined
|
|
39
|
+
: undefined, {
|
|
40
|
+
workspaceRoot,
|
|
41
|
+
origins: snapshot.origins,
|
|
42
|
+
});
|
|
26
43
|
return {
|
|
27
44
|
value: result.value,
|
|
28
45
|
...(result.content ? { content: result.content } : {}),
|