@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 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
- # Or use local stdio for testing:
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([HttpUpstreamSchema, StdioUpstreamSchema]);
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 || !this.transport) return;
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 isStdio = (info) => info.type === "stdio";
1112
- if (isStdio(this.info)) {
1113
- return this.callStdio(request);
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 callStdio(request) {
1187
+ async callMcpClient(request) {
1119
1188
  if (!this.mcpClient) {
1120
- return { jsonrpc: "2.0", id: request.id, error: { code: -32603, message: "Stdio client not initialized" } };
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 }, "Stdio call failed");
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 stdio transport"
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") throw new Error("Unreachable");
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 body = new URLSearchParams();
1306
- body.set("grant_type", "refresh_token");
1307
- body.set("refresh_token", creds.refreshToken);
1308
- body.set("client_id", creds.clientId);
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
- body.set("client_secret", creds.clientSecret);
1390
+ payload.client_secret = creds.clientSecret;
1311
1391
  }
1312
- const response = await axios3.post(creds.tokenUrl, body, {
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 expiresInSeconds = Number(expires_in) || 3600;
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
- try {
1549
- const manifest = await client.getManifest(context);
1550
- if (manifest && manifest.tools) {
1551
- tools = manifest.tools;
1552
- } else {
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
- if (tools && tools.length > 0) {
1569
- this.schemaCache.set(packageId, tools);
1570
- this.logger.info({ upstreamId: packageId, toolCount: tools.length }, "Discovered tools from upstream");
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 body = new URLSearchParams();
3450
- body.set("grant_type", "authorization_code");
3451
- body.set("code", code);
3452
- body.set("redirect_uri", redirectUri);
3453
- body.set("client_id", options.clientId);
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
- body.set("client_secret", options.clientSecret);
3564
+ payload.client_secret = options.clientSecret;
3456
3565
  }
3457
3566
  if (codeVerifier) {
3458
- body.set("code_verifier", codeVerifier);
3567
+ payload.code_verifier = codeVerifier;
3459
3568
  }
3460
3569
  if (resolvedResource) {
3461
- body.set("resource", resolvedResource);
3570
+ payload.resource = resolvedResource;
3462
3571
  }
3463
- const response = await axios4.post(resolvedTokenUrl, body, {
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"