@mhingston5/conduit 1.1.6 → 1.1.7
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 +29 -1
- package/dist/index.js +157 -41
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/auth.cmd.ts +26 -14
- package/src/core/config.service.ts +22 -1
- package/src/gateway/auth.service.ts +55 -13
- package/src/gateway/gateway.service.ts +22 -14
- package/src/gateway/upstream.client.ts +84 -15
- package/tests/__snapshots__/assets.test.ts.snap +17 -15
- package/tests/auth.service.test.ts +57 -0
- package/tests/config.service.test.ts +29 -1
- package/tests/upstream.transports.test.ts +117 -0
- package/tests/debug.fallback.test.ts +0 -40
- package/tests/debug_upstream.ts +0 -69
package/README.md
CHANGED
|
@@ -66,6 +66,20 @@ upstreams:
|
|
|
66
66
|
- id: github
|
|
67
67
|
type: http
|
|
68
68
|
url: "http://localhost:3000/mcp"
|
|
69
|
+
|
|
70
|
+
# Remote MCP servers that use Streamable HTTP (preferred) / SSE:
|
|
71
|
+
- id: atlassian
|
|
72
|
+
type: streamableHttp
|
|
73
|
+
url: "https://mcp.atlassian.com/v1/sse"
|
|
74
|
+
credentials:
|
|
75
|
+
type: oauth2
|
|
76
|
+
clientId: ${ATLASSIAN_CLIENT_ID}
|
|
77
|
+
clientSecret: ${ATLASSIAN_CLIENT_SECRET}
|
|
78
|
+
tokenUrl: "https://auth.atlassian.com/oauth/token"
|
|
79
|
+
refreshToken: ${ATLASSIAN_REFRESH_TOKEN}
|
|
80
|
+
# Atlassian expects JSON token requests:
|
|
81
|
+
tokenRequestFormat: json
|
|
82
|
+
|
|
69
83
|
- id: slack
|
|
70
84
|
type: http
|
|
71
85
|
url: "https://your-mcp-server/mcp"
|
|
@@ -75,7 +89,8 @@ upstreams:
|
|
|
75
89
|
clientSecret: ${SLACK_CLIENT_SECRET}
|
|
76
90
|
tokenUrl: "https://slack.com/api/oauth.v2.access"
|
|
77
91
|
refreshToken: ${SLACK_REFRESH_TOKEN}
|
|
78
|
-
|
|
92
|
+
|
|
93
|
+
# Or use local stdio for testing:
|
|
79
94
|
- id: filesystem
|
|
80
95
|
type: stdio
|
|
81
96
|
command: npx
|
|
@@ -93,10 +108,23 @@ npx conduit auth \
|
|
|
93
108
|
--auth-url <url> \
|
|
94
109
|
--token-url <url> \
|
|
95
110
|
--scopes <scopes>
|
|
111
|
+
|
|
112
|
+
For Atlassian (3LO), include `offline_access` and set the audience:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npx conduit auth \
|
|
116
|
+
--client-id <id> \
|
|
117
|
+
--client-secret <secret> \
|
|
118
|
+
--auth-url "https://auth.atlassian.com/authorize?audience=api.atlassian.com&prompt=consent" \
|
|
119
|
+
--token-url "https://auth.atlassian.com/oauth/token" \
|
|
120
|
+
--scopes "offline_access,read:me"
|
|
121
|
+
```
|
|
96
122
|
```
|
|
97
123
|
|
|
98
124
|
This will start a temporary local server, open your browser for authorization, and print the generated `credentials` block for your `conduit.yaml`.
|
|
99
125
|
|
|
126
|
+
Note: some providers (including Atlassian) use rotating refresh tokens. Conduit will cache the latest refresh token in-memory while running, but it does not currently persist the rotated token back into `conduit.yaml`. If you restart Conduit and your old refresh token has expired/rotated, re-run `conduit auth` and update your config.
|
|
127
|
+
|
|
100
128
|
For GitHub MCP (remote server OAuth), you can auto-discover endpoints and use PKCE:
|
|
101
129
|
|
|
102
130
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,8 @@ var UpstreamCredentialsSchema = z.object({
|
|
|
29
29
|
tokenUrl: z.string().optional(),
|
|
30
30
|
refreshToken: z.string().optional(),
|
|
31
31
|
scopes: z.array(z.string()).optional(),
|
|
32
|
+
tokenRequestFormat: z.enum(["form", "json"]).optional(),
|
|
33
|
+
tokenParams: z.record(z.string(), z.string()).optional(),
|
|
32
34
|
apiKey: z.string().optional(),
|
|
33
35
|
bearerToken: z.string().optional(),
|
|
34
36
|
headerName: z.string().optional()
|
|
@@ -39,6 +41,18 @@ var HttpUpstreamSchema = z.object({
|
|
|
39
41
|
url: z.string(),
|
|
40
42
|
credentials: UpstreamCredentialsSchema.optional()
|
|
41
43
|
});
|
|
44
|
+
var StreamableHttpUpstreamSchema = z.object({
|
|
45
|
+
id: z.string(),
|
|
46
|
+
type: z.literal("streamableHttp"),
|
|
47
|
+
url: z.string(),
|
|
48
|
+
credentials: UpstreamCredentialsSchema.optional()
|
|
49
|
+
});
|
|
50
|
+
var SseUpstreamSchema = z.object({
|
|
51
|
+
id: z.string(),
|
|
52
|
+
type: z.literal("sse"),
|
|
53
|
+
url: z.string(),
|
|
54
|
+
credentials: UpstreamCredentialsSchema.optional()
|
|
55
|
+
});
|
|
42
56
|
var StdioUpstreamSchema = z.object({
|
|
43
57
|
id: z.string(),
|
|
44
58
|
type: z.literal("stdio"),
|
|
@@ -46,7 +60,12 @@ var StdioUpstreamSchema = z.object({
|
|
|
46
60
|
args: z.array(z.string()).optional(),
|
|
47
61
|
env: z.record(z.string(), z.string()).optional()
|
|
48
62
|
});
|
|
49
|
-
var UpstreamInfoSchema = z.union([
|
|
63
|
+
var UpstreamInfoSchema = z.union([
|
|
64
|
+
HttpUpstreamSchema,
|
|
65
|
+
StreamableHttpUpstreamSchema,
|
|
66
|
+
SseUpstreamSchema,
|
|
67
|
+
StdioUpstreamSchema
|
|
68
|
+
]);
|
|
50
69
|
var ConfigSchema = z.object({
|
|
51
70
|
port: z.union([z.string(), z.number()]).default("3000").transform((v) => Number(v)),
|
|
52
71
|
nodeEnv: z.enum(["development", "production", "test"]).default("development"),
|
|
@@ -1061,6 +1080,8 @@ var RequestController = class {
|
|
|
1061
1080
|
import axios2 from "axios";
|
|
1062
1081
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
1063
1082
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
1083
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
1084
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
1064
1085
|
import { z as z2 } from "zod";
|
|
1065
1086
|
var UpstreamClient = class {
|
|
1066
1087
|
logger;
|
|
@@ -1092,11 +1113,60 @@ var UpstreamClient = class {
|
|
|
1092
1113
|
}, {
|
|
1093
1114
|
capabilities: {}
|
|
1094
1115
|
});
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
if (this.info.type === "streamableHttp") {
|
|
1119
|
+
this.transport = new StreamableHTTPClientTransport(new URL(this.info.url), {
|
|
1120
|
+
fetch: this.createAuthedFetch()
|
|
1121
|
+
});
|
|
1122
|
+
this.mcpClient = new Client({
|
|
1123
|
+
name: "conduit-gateway",
|
|
1124
|
+
version: "1.0.0"
|
|
1125
|
+
}, {
|
|
1126
|
+
capabilities: {}
|
|
1127
|
+
});
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (this.info.type === "sse") {
|
|
1131
|
+
this.mcpClient = new Client({
|
|
1132
|
+
name: "conduit-gateway",
|
|
1133
|
+
version: "1.0.0"
|
|
1134
|
+
}, {
|
|
1135
|
+
capabilities: {}
|
|
1136
|
+
});
|
|
1095
1137
|
}
|
|
1096
1138
|
}
|
|
1139
|
+
createAuthedFetch() {
|
|
1140
|
+
const creds = this.info.credentials;
|
|
1141
|
+
if (!creds) return fetch;
|
|
1142
|
+
return async (input, init = {}) => {
|
|
1143
|
+
const headers = new Headers(init.headers || {});
|
|
1144
|
+
const authHeaders = await this.authService.getAuthHeaders(creds);
|
|
1145
|
+
for (const [k, v] of Object.entries(authHeaders)) {
|
|
1146
|
+
headers.set(k, v);
|
|
1147
|
+
}
|
|
1148
|
+
return fetch(input, { ...init, headers });
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1097
1151
|
async ensureConnected() {
|
|
1098
|
-
if (!this.mcpClient
|
|
1152
|
+
if (!this.mcpClient) return;
|
|
1153
|
+
if (!this.transport && this.info.type === "sse") {
|
|
1154
|
+
const authHeaders = this.info.credentials ? await this.authService.getAuthHeaders(this.info.credentials) : {};
|
|
1155
|
+
this.transport = new SSEClientTransport(new URL(this.info.url), {
|
|
1156
|
+
fetch: this.createAuthedFetch(),
|
|
1157
|
+
eventSourceInit: { headers: authHeaders },
|
|
1158
|
+
requestInit: { headers: authHeaders }
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
if (!this.transport) return;
|
|
1099
1162
|
if (this.connected) return;
|
|
1163
|
+
if (this.info.type === "streamableHttp" || this.info.type === "sse") {
|
|
1164
|
+
const securityResult = await this.urlValidator.validateUrl(this.info.url);
|
|
1165
|
+
if (!securityResult.valid) {
|
|
1166
|
+
this.logger.error({ url: this.info.url }, "Blocked upstream URL (SSRF)");
|
|
1167
|
+
throw new Error(securityResult.message || "Forbidden URL");
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1100
1170
|
try {
|
|
1101
1171
|
this.logger.debug("Connecting to upstream transport...");
|
|
1102
1172
|
await this.mcpClient.connect(this.transport);
|
|
@@ -1108,16 +1178,15 @@ var UpstreamClient = class {
|
|
|
1108
1178
|
}
|
|
1109
1179
|
}
|
|
1110
1180
|
async call(request, context) {
|
|
1111
|
-
const
|
|
1112
|
-
if (
|
|
1113
|
-
return this.
|
|
1114
|
-
} else {
|
|
1115
|
-
return this.callHttp(request, context);
|
|
1181
|
+
const usesMcpClientTransport = (info) => info.type === "stdio" || info.type === "streamableHttp" || info.type === "sse";
|
|
1182
|
+
if (usesMcpClientTransport(this.info)) {
|
|
1183
|
+
return this.callMcpClient(request);
|
|
1116
1184
|
}
|
|
1185
|
+
return this.callHttp(request, context);
|
|
1117
1186
|
}
|
|
1118
|
-
async
|
|
1187
|
+
async callMcpClient(request) {
|
|
1119
1188
|
if (!this.mcpClient) {
|
|
1120
|
-
return { jsonrpc: "2.0", id: request.id, error: { code: -32603, message: "
|
|
1189
|
+
return { jsonrpc: "2.0", id: request.id, error: { code: -32603, message: "MCP client not initialized" } };
|
|
1121
1190
|
}
|
|
1122
1191
|
try {
|
|
1123
1192
|
await this.ensureConnected();
|
|
@@ -1157,19 +1226,21 @@ var UpstreamClient = class {
|
|
|
1157
1226
|
};
|
|
1158
1227
|
}
|
|
1159
1228
|
} catch (error) {
|
|
1160
|
-
this.logger.error({ err: error }, "
|
|
1229
|
+
this.logger.error({ err: error }, "MCP call failed");
|
|
1161
1230
|
return {
|
|
1162
1231
|
jsonrpc: "2.0",
|
|
1163
1232
|
id: request.id,
|
|
1164
1233
|
error: {
|
|
1165
1234
|
code: error.code || -32603,
|
|
1166
|
-
message: error.message || "Internal error in
|
|
1235
|
+
message: error.message || "Internal error in MCP transport"
|
|
1167
1236
|
}
|
|
1168
1237
|
};
|
|
1169
1238
|
}
|
|
1170
1239
|
}
|
|
1171
1240
|
async callHttp(request, context) {
|
|
1172
|
-
if (this.info.type === "stdio"
|
|
1241
|
+
if (this.info.type === "stdio" || this.info.type === "streamableHttp" || this.info.type === "sse") {
|
|
1242
|
+
throw new Error("Unreachable");
|
|
1243
|
+
}
|
|
1173
1244
|
const url = this.info.url;
|
|
1174
1245
|
const headers = {
|
|
1175
1246
|
"Content-Type": "application/json",
|
|
@@ -1218,7 +1289,7 @@ var UpstreamClient = class {
|
|
|
1218
1289
|
}
|
|
1219
1290
|
}
|
|
1220
1291
|
async getManifest(context) {
|
|
1221
|
-
if (this.info.type !== "http") return null;
|
|
1292
|
+
if (this.info.type && this.info.type !== "http") return null;
|
|
1222
1293
|
try {
|
|
1223
1294
|
const baseUrl = this.info.url.replace(/\/$/, "");
|
|
1224
1295
|
const manifestUrl = `${baseUrl}/conduit.manifest.json`;
|
|
@@ -1258,6 +1329,8 @@ var AuthService = class {
|
|
|
1258
1329
|
logger;
|
|
1259
1330
|
// Cache tokens separately from credentials to avoid mutation
|
|
1260
1331
|
tokenCache = /* @__PURE__ */ new Map();
|
|
1332
|
+
// Keep the latest refresh token in-memory (rotating tokens)
|
|
1333
|
+
refreshTokenCache = /* @__PURE__ */ new Map();
|
|
1261
1334
|
// Prevent concurrent refresh requests for the same client
|
|
1262
1335
|
refreshLocks = /* @__PURE__ */ new Map();
|
|
1263
1336
|
constructor(logger) {
|
|
@@ -1302,25 +1375,53 @@ var AuthService = class {
|
|
|
1302
1375
|
}
|
|
1303
1376
|
this.logger.info({ tokenUrl: creds.tokenUrl, clientId: creds.clientId }, "Refreshing OAuth2 token");
|
|
1304
1377
|
try {
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1378
|
+
const tokenUrl = creds.tokenUrl;
|
|
1379
|
+
const cachedRefreshToken = this.refreshTokenCache.get(cacheKey);
|
|
1380
|
+
const refreshToken = cachedRefreshToken || creds.refreshToken;
|
|
1381
|
+
if (!refreshToken) {
|
|
1382
|
+
throw new Error("OAuth2 credentials missing required fields for refresh");
|
|
1383
|
+
}
|
|
1384
|
+
const payload = {
|
|
1385
|
+
grant_type: "refresh_token",
|
|
1386
|
+
refresh_token: refreshToken,
|
|
1387
|
+
client_id: creds.clientId
|
|
1388
|
+
};
|
|
1309
1389
|
if (creds.clientSecret) {
|
|
1310
|
-
|
|
1390
|
+
payload.client_secret = creds.clientSecret;
|
|
1311
1391
|
}
|
|
1312
|
-
|
|
1392
|
+
if (creds.tokenParams) {
|
|
1393
|
+
Object.assign(payload, creds.tokenParams);
|
|
1394
|
+
}
|
|
1395
|
+
const requestFormat = (() => {
|
|
1396
|
+
if (creds.tokenRequestFormat) return creds.tokenRequestFormat;
|
|
1397
|
+
try {
|
|
1398
|
+
const hostname = new URL(tokenUrl).hostname;
|
|
1399
|
+
if (hostname === "auth.atlassian.com") return "json";
|
|
1400
|
+
} catch {
|
|
1401
|
+
}
|
|
1402
|
+
return "form";
|
|
1403
|
+
})();
|
|
1404
|
+
const response = requestFormat === "json" ? await axios3.post(tokenUrl, payload, {
|
|
1405
|
+
headers: {
|
|
1406
|
+
"Content-Type": "application/json",
|
|
1407
|
+
"Accept": "application/json"
|
|
1408
|
+
}
|
|
1409
|
+
}) : await axios3.post(tokenUrl, new URLSearchParams(payload), {
|
|
1313
1410
|
headers: {
|
|
1314
1411
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
1315
1412
|
"Accept": "application/json"
|
|
1316
1413
|
}
|
|
1317
1414
|
});
|
|
1318
|
-
const { access_token, expires_in } = response.data;
|
|
1319
|
-
const
|
|
1415
|
+
const { access_token, expires_in, refresh_token } = response.data;
|
|
1416
|
+
const expiresInRaw = Number(expires_in);
|
|
1417
|
+
const expiresInSeconds = Number.isFinite(expiresInRaw) ? expiresInRaw : 3600;
|
|
1320
1418
|
this.tokenCache.set(cacheKey, {
|
|
1321
1419
|
accessToken: access_token,
|
|
1322
1420
|
expiresAt: Date.now() + expiresInSeconds * 1e3
|
|
1323
1421
|
});
|
|
1422
|
+
if (typeof refresh_token === "string" && refresh_token.length > 0) {
|
|
1423
|
+
this.refreshTokenCache.set(cacheKey, refresh_token);
|
|
1424
|
+
}
|
|
1324
1425
|
return `Bearer ${access_token}`;
|
|
1325
1426
|
} catch (err) {
|
|
1326
1427
|
const errorMsg = err.response?.data?.error_description || err.response?.data?.error || err.message;
|
|
@@ -1545,11 +1646,18 @@ var GatewayService = class {
|
|
|
1545
1646
|
}
|
|
1546
1647
|
let tools = this.schemaCache.get(packageId);
|
|
1547
1648
|
if (!tools) {
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1649
|
+
if (typeof client.getManifest === "function") {
|
|
1650
|
+
try {
|
|
1651
|
+
const manifest = await client.getManifest(context);
|
|
1652
|
+
if (manifest && manifest.tools) {
|
|
1653
|
+
tools = manifest.tools;
|
|
1654
|
+
}
|
|
1655
|
+
} catch (e) {
|
|
1656
|
+
this.logger.debug({ upstreamId: packageId, err: e.message }, "Manifest fetch failed (will fallback)");
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
if (!tools) {
|
|
1660
|
+
try {
|
|
1553
1661
|
if (typeof client.listTools === "function") {
|
|
1554
1662
|
tools = await client.listTools();
|
|
1555
1663
|
} else {
|
|
@@ -1564,13 +1672,13 @@ var GatewayService = class {
|
|
|
1564
1672
|
this.logger.warn({ upstreamId: packageId, error: response.error }, "Failed to discover tools via RPC");
|
|
1565
1673
|
}
|
|
1566
1674
|
}
|
|
1675
|
+
} catch (e) {
|
|
1676
|
+
this.logger.error({ upstreamId: packageId, err: e.message }, "Error during tool discovery");
|
|
1567
1677
|
}
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
}
|
|
1572
|
-
} catch (e) {
|
|
1573
|
-
this.logger.error({ upstreamId: packageId, err: e.message }, "Error during tool discovery");
|
|
1678
|
+
}
|
|
1679
|
+
if (tools && tools.length > 0) {
|
|
1680
|
+
this.schemaCache.set(packageId, tools);
|
|
1681
|
+
this.logger.info({ upstreamId: packageId, toolCount: tools.length }, "Discovered tools from upstream");
|
|
1574
1682
|
}
|
|
1575
1683
|
}
|
|
1576
1684
|
if (!tools) tools = [];
|
|
@@ -3446,21 +3554,29 @@ async function handleAuth(options) {
|
|
|
3446
3554
|
return;
|
|
3447
3555
|
}
|
|
3448
3556
|
try {
|
|
3449
|
-
const
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3557
|
+
const payload = {
|
|
3558
|
+
grant_type: "authorization_code",
|
|
3559
|
+
code,
|
|
3560
|
+
redirect_uri: redirectUri,
|
|
3561
|
+
client_id: options.clientId
|
|
3562
|
+
};
|
|
3454
3563
|
if (options.clientSecret) {
|
|
3455
|
-
|
|
3564
|
+
payload.client_secret = options.clientSecret;
|
|
3456
3565
|
}
|
|
3457
3566
|
if (codeVerifier) {
|
|
3458
|
-
|
|
3567
|
+
payload.code_verifier = codeVerifier;
|
|
3459
3568
|
}
|
|
3460
3569
|
if (resolvedResource) {
|
|
3461
|
-
|
|
3570
|
+
payload.resource = resolvedResource;
|
|
3462
3571
|
}
|
|
3463
|
-
const
|
|
3572
|
+
const tokenHostname = new URL(resolvedTokenUrl).hostname;
|
|
3573
|
+
const useJson = tokenHostname === "auth.atlassian.com";
|
|
3574
|
+
const response = useJson ? await axios4.post(resolvedTokenUrl, payload, {
|
|
3575
|
+
headers: {
|
|
3576
|
+
"Content-Type": "application/json",
|
|
3577
|
+
"Accept": "application/json"
|
|
3578
|
+
}
|
|
3579
|
+
}) : await axios4.post(resolvedTokenUrl, new URLSearchParams(payload), {
|
|
3464
3580
|
headers: {
|
|
3465
3581
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
3466
3582
|
"Accept": "application/json"
|