@tinycloud/mcp 0.8.3-beta.6 → 0.8.3-beta.8
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 +25 -0
- package/dist/cli.cjs +3 -3
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +3 -3
- package/dist/cli.js.map +1 -1
- package/dist/http-cli.cjs +184 -15
- package/dist/http-cli.cjs.map +1 -1
- package/dist/http-cli.js +178 -9
- package/dist/http-cli.js.map +1 -1
- package/dist/http.cjs +172 -14
- package/dist/http.cjs.map +1 -1
- package/dist/http.d.cts +22 -0
- package/dist/http.d.ts +22 -0
- package/dist/http.js +166 -8
- package/dist/http.js.map +1 -1
- package/dist/index.cjs +172 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +166 -8
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +3 -3
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +3 -3
- package/dist/server.js.map +1 -1
- package/package.json +3 -3
package/dist/http-cli.cjs
CHANGED
|
@@ -46,7 +46,9 @@ function createOpenKeyTokenVerifier(config) {
|
|
|
46
46
|
issuer: config.issuer,
|
|
47
47
|
audience: config.audience
|
|
48
48
|
});
|
|
49
|
-
|
|
49
|
+
const info = authInfo(token, payload, config);
|
|
50
|
+
if (config.tokenStatus !== void 0) await requireUsableStatus(config.tokenStatus, token);
|
|
51
|
+
return info;
|
|
50
52
|
} catch (error) {
|
|
51
53
|
if (error instanceof import_jose.errors.JOSEError) {
|
|
52
54
|
throw new import_server.OAuthError(import_server.OAuthErrorCode.InvalidToken, "The OpenKey access token is invalid.");
|
|
@@ -56,6 +58,16 @@ function createOpenKeyTokenVerifier(config) {
|
|
|
56
58
|
}
|
|
57
59
|
};
|
|
58
60
|
}
|
|
61
|
+
async function requireUsableStatus(checker, token) {
|
|
62
|
+
const status = await checker.tokenStatus(token);
|
|
63
|
+
if (status === "active") return;
|
|
64
|
+
if (status === "inactive") {
|
|
65
|
+
throw new import_server.OAuthError(import_server.OAuthErrorCode.InvalidToken, "The OpenKey access token is no longer active.");
|
|
66
|
+
}
|
|
67
|
+
if (!checker.toleratesUnknown(token)) {
|
|
68
|
+
throw new import_server.OAuthError(import_server.OAuthErrorCode.ServerError, "The OpenKey access token status is unavailable.");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
59
71
|
async function loadOAuthMetadata(metadataUrl) {
|
|
60
72
|
const response = await fetch(metadataUrl, {
|
|
61
73
|
headers: { accept: "application/json" },
|
|
@@ -121,6 +133,150 @@ function firstString(...values) {
|
|
|
121
133
|
return values.find((value) => typeof value === "string" && value.length > 0);
|
|
122
134
|
}
|
|
123
135
|
|
|
136
|
+
// src/token-status.ts
|
|
137
|
+
var import_node_crypto = require("crypto");
|
|
138
|
+
var import_jose2 = require("jose");
|
|
139
|
+
var ACTIVE_CACHE_MS = 3e4;
|
|
140
|
+
var DEFAULT_TOKEN_LIFETIME_SECONDS = 300;
|
|
141
|
+
var DEFAULT_TIMEOUT_MS = 3e3;
|
|
142
|
+
var MAX_CACHED_TOKENS = 1e4;
|
|
143
|
+
function createTokenStatusChecker(config) {
|
|
144
|
+
const doFetch = config.fetch ?? fetch;
|
|
145
|
+
const clock = config.now ?? Date.now;
|
|
146
|
+
const log = config.log ?? ((message) => void process.stderr.write(`[tinycloud-mcp-http] ${message}
|
|
147
|
+
`));
|
|
148
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
149
|
+
const inactive = /* @__PURE__ */ new Map();
|
|
150
|
+
const active = /* @__PURE__ */ new Map();
|
|
151
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
152
|
+
const confirmedActive = /* @__PURE__ */ new Map();
|
|
153
|
+
let credentialsRefused = false;
|
|
154
|
+
async function introspect(token) {
|
|
155
|
+
const headers = {
|
|
156
|
+
accept: "application/json",
|
|
157
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
158
|
+
};
|
|
159
|
+
const body = new URLSearchParams({ token, token_type_hint: "access_token" });
|
|
160
|
+
if (config.authMethod === "basic") {
|
|
161
|
+
const encode = (value) => encodeURIComponent(value).replace(/%20/g, "+");
|
|
162
|
+
const credentials = `${encode(config.clientId)}:${encode(config.clientSecret)}`;
|
|
163
|
+
headers.authorization = `Basic ${Buffer.from(credentials).toString("base64")}`;
|
|
164
|
+
} else {
|
|
165
|
+
body.set("client_id", config.clientId);
|
|
166
|
+
body.set("client_secret", config.clientSecret);
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const response = await doFetch(config.endpoint, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers,
|
|
172
|
+
body,
|
|
173
|
+
redirect: "error",
|
|
174
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
175
|
+
});
|
|
176
|
+
if (response.status === 401) return "credentials";
|
|
177
|
+
if (response.status !== 200) return "unknown";
|
|
178
|
+
const result = await response.json();
|
|
179
|
+
if (typeof result !== "object" || result === null || !("active" in result)) return "unknown";
|
|
180
|
+
if (result.active === false) return "inactive";
|
|
181
|
+
if (result.active !== true) return "unknown";
|
|
182
|
+
return answerMatchesToken(Object.fromEntries(Object.entries(result)), token, config.resourceUrl) ? "active" : "mismatch";
|
|
183
|
+
} catch {
|
|
184
|
+
return "unknown";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function query(key, token) {
|
|
188
|
+
const outcome = await introspect(token);
|
|
189
|
+
if (outcome === "credentials") {
|
|
190
|
+
if (!credentialsRefused) {
|
|
191
|
+
log("OpenKey token introspection refused the MCP client credentials (401); token checks fail closed until it accepts them.");
|
|
192
|
+
}
|
|
193
|
+
credentialsRefused = true;
|
|
194
|
+
return "unknown";
|
|
195
|
+
}
|
|
196
|
+
if (outcome === "unknown") return outcome;
|
|
197
|
+
const now = clock();
|
|
198
|
+
prune(now);
|
|
199
|
+
if (outcome === "mismatch") {
|
|
200
|
+
inactive.set(key, tokenExpiryMs(token, now));
|
|
201
|
+
confirmedActive.delete(key);
|
|
202
|
+
return "inactive";
|
|
203
|
+
}
|
|
204
|
+
credentialsRefused = false;
|
|
205
|
+
if (outcome === "inactive") {
|
|
206
|
+
inactive.set(key, tokenExpiryMs(token, now));
|
|
207
|
+
confirmedActive.delete(key);
|
|
208
|
+
} else {
|
|
209
|
+
active.set(key, now + ACTIVE_CACHE_MS);
|
|
210
|
+
confirmedActive.delete(key);
|
|
211
|
+
confirmedActive.set(key, { at: now, expiresAt: tokenExpiryMs(token, now) });
|
|
212
|
+
}
|
|
213
|
+
return outcome;
|
|
214
|
+
}
|
|
215
|
+
function prune(now) {
|
|
216
|
+
for (const [key, until] of active) if (until <= now) active.delete(key);
|
|
217
|
+
for (const [key, until] of inactive) if (until <= now) inactive.delete(key);
|
|
218
|
+
for (const [key, entry] of confirmedActive) if (entry.expiresAt <= now) confirmedActive.delete(key);
|
|
219
|
+
for (const cache of [active, inactive, confirmedActive]) {
|
|
220
|
+
while (cache.size >= MAX_CACHED_TOKENS) {
|
|
221
|
+
const oldest = cache.keys().next().value;
|
|
222
|
+
if (oldest === void 0) break;
|
|
223
|
+
cache.delete(oldest);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
async tokenStatus(token) {
|
|
229
|
+
const key = (0, import_node_crypto.createHash)("sha256").update(token).digest("hex");
|
|
230
|
+
const now = clock();
|
|
231
|
+
const inactiveUntil = inactive.get(key);
|
|
232
|
+
if (inactiveUntil !== void 0 && inactiveUntil > now) return "inactive";
|
|
233
|
+
const activeUntil = active.get(key);
|
|
234
|
+
if (activeUntil !== void 0 && activeUntil > now) return "active";
|
|
235
|
+
const pending = inFlight.get(key);
|
|
236
|
+
if (pending !== void 0) return pending;
|
|
237
|
+
const asking = query(key, token).finally(() => inFlight.delete(key));
|
|
238
|
+
inFlight.set(key, asking);
|
|
239
|
+
return asking;
|
|
240
|
+
},
|
|
241
|
+
toleratesUnknown(token) {
|
|
242
|
+
if (credentialsRefused) return false;
|
|
243
|
+
const confirmed = confirmedActive.get((0, import_node_crypto.createHash)("sha256").update(token).digest("hex"));
|
|
244
|
+
if (confirmed === void 0) return false;
|
|
245
|
+
const now = clock();
|
|
246
|
+
return confirmed.expiresAt > now && now - confirmed.at < tokenLifetimeSeconds(token) * 1e3;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function answerMatchesToken(answer, token, resourceUrl) {
|
|
251
|
+
const jwt = claims(token);
|
|
252
|
+
if (typeof jwt.sub === "string" && answer.sub !== jwt.sub) return false;
|
|
253
|
+
const jwtClient = [jwt.azp, jwt.client_id].find((value) => typeof value === "string" && value.length > 0);
|
|
254
|
+
if (jwtClient !== void 0 && answer.client_id !== jwtClient) return false;
|
|
255
|
+
if (jwt.aud === void 0) return true;
|
|
256
|
+
const answered = audienceList(answer.aud);
|
|
257
|
+
const signed = audienceList(jwt.aud);
|
|
258
|
+
return answered.length > 0 && answered.includes(resourceUrl) && answered.every((entry) => signed.includes(entry));
|
|
259
|
+
}
|
|
260
|
+
function audienceList(value) {
|
|
261
|
+
const list = typeof value === "string" ? [value] : value;
|
|
262
|
+
return Array.isArray(list) && list.every((entry) => typeof entry === "string") ? list : [];
|
|
263
|
+
}
|
|
264
|
+
function tokenExpiryMs(token, now) {
|
|
265
|
+
const exp = claims(token).exp;
|
|
266
|
+
return typeof exp === "number" ? exp * 1e3 : now + DEFAULT_TOKEN_LIFETIME_SECONDS * 1e3;
|
|
267
|
+
}
|
|
268
|
+
function tokenLifetimeSeconds(token) {
|
|
269
|
+
const { exp, iat } = claims(token);
|
|
270
|
+
return typeof exp === "number" && typeof iat === "number" && exp > iat ? exp - iat : DEFAULT_TOKEN_LIFETIME_SECONDS;
|
|
271
|
+
}
|
|
272
|
+
function claims(token) {
|
|
273
|
+
try {
|
|
274
|
+
return (0, import_jose2.decodeJwt)(token);
|
|
275
|
+
} catch {
|
|
276
|
+
return {};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
124
280
|
// src/logging.ts
|
|
125
281
|
function logToolCall(tool, code, startedAt, replayedGrants) {
|
|
126
282
|
process.stderr.write(`${JSON.stringify({
|
|
@@ -133,7 +289,7 @@ function logToolCall(tool, code, startedAt, replayedGrants) {
|
|
|
133
289
|
}
|
|
134
290
|
|
|
135
291
|
// src/remote-store.ts
|
|
136
|
-
var
|
|
292
|
+
var import_node_crypto2 = require("crypto");
|
|
137
293
|
var import_promises = require("fs/promises");
|
|
138
294
|
var import_node_path = require("path");
|
|
139
295
|
var import_node_sdk2 = require("@tinycloud/node-sdk");
|
|
@@ -725,7 +881,7 @@ var RemoteTenantStore = class {
|
|
|
725
881
|
{ tenantId, approvalEpoch: tenant.approvalEpoch, kind, requestId, permissions, ownerDids }
|
|
726
882
|
);
|
|
727
883
|
if (reusable !== void 0) return reusable;
|
|
728
|
-
const nonce = (0,
|
|
884
|
+
const nonce = (0, import_node_crypto2.randomBytes)(24).toString("base64url");
|
|
729
885
|
const created = Date.now();
|
|
730
886
|
const pending = {
|
|
731
887
|
version: 1,
|
|
@@ -750,7 +906,7 @@ var RemoteTenantStore = class {
|
|
|
750
906
|
const expected = this.#signState(tenantId, nonce);
|
|
751
907
|
const supplied = Buffer.from(signature, "base64url");
|
|
752
908
|
const expectedBytes = Buffer.from(expected, "base64url");
|
|
753
|
-
if (supplied.length !== expectedBytes.length || !(0,
|
|
909
|
+
if (supplied.length !== expectedBytes.length || !(0, import_node_crypto2.timingSafeEqual)(supplied, expectedBytes)) {
|
|
754
910
|
throw new ApprovalRequestError("state_invalid", "Approval state is invalid.");
|
|
755
911
|
}
|
|
756
912
|
const stateRoot = (0, import_node_path.join)(this.#config.stateDir, "tenants", tenantId);
|
|
@@ -802,10 +958,10 @@ var RemoteTenantStore = class {
|
|
|
802
958
|
return url.toString();
|
|
803
959
|
}
|
|
804
960
|
#tenantId(subject) {
|
|
805
|
-
return (0,
|
|
961
|
+
return (0, import_node_crypto2.createHmac)("sha256", this.#config.stateSecret).update(`tenant:${subject}`).digest("hex");
|
|
806
962
|
}
|
|
807
963
|
#signState(tenantId, nonce) {
|
|
808
|
-
return (0,
|
|
964
|
+
return (0, import_node_crypto2.createHmac)("sha256", this.#config.stateSecret).update(`approval:${tenantId}:${nonce}`).digest("base64url");
|
|
809
965
|
}
|
|
810
966
|
#profilePath(stateRoot) {
|
|
811
967
|
return (0, import_node_path.join)(stateRoot, ".tinycloud", "profiles", PROFILE, "profile.json");
|
|
@@ -907,7 +1063,7 @@ async function secureDirectory(path) {
|
|
|
907
1063
|
async function secureWrite(path, value) {
|
|
908
1064
|
const directory = (0, import_node_path.dirname)(path);
|
|
909
1065
|
await secureDirectory(directory);
|
|
910
|
-
const temporary = `${path}.${process.pid}.${(0,
|
|
1066
|
+
const temporary = `${path}.${process.pid}.${(0, import_node_crypto2.randomBytes)(8).toString("hex")}.tmp`;
|
|
911
1067
|
await (0, import_promises.writeFile)(temporary, `${JSON.stringify(value, jsonReplacer, 2)}
|
|
912
1068
|
`, { mode: 384 });
|
|
913
1069
|
await (0, import_promises.rename)(temporary, path);
|
|
@@ -1399,7 +1555,7 @@ function outcomeCode(result) {
|
|
|
1399
1555
|
// package.json
|
|
1400
1556
|
var package_default = {
|
|
1401
1557
|
name: "@tinycloud/mcp",
|
|
1402
|
-
version: "0.8.3-beta.
|
|
1558
|
+
version: "0.8.3-beta.8",
|
|
1403
1559
|
description: "TinyCloud delegated operations MCP server",
|
|
1404
1560
|
keywords: [
|
|
1405
1561
|
"tinycloud",
|
|
@@ -1470,9 +1626,9 @@ var package_default = {
|
|
|
1470
1626
|
dependencies: {
|
|
1471
1627
|
"@modelcontextprotocol/node": "2.0.0-beta.5",
|
|
1472
1628
|
"@modelcontextprotocol/server": "2.0.0-beta.5",
|
|
1473
|
-
"@tinycloud/node-sdk": "3.8.0-beta.
|
|
1629
|
+
"@tinycloud/node-sdk": "3.8.0-beta.6",
|
|
1474
1630
|
"@tinycloud/node-sdk-wasm": "1.7.8",
|
|
1475
|
-
"@tinycloud/operations": "0.7.7-beta.
|
|
1631
|
+
"@tinycloud/operations": "0.7.7-beta.7",
|
|
1476
1632
|
jose: "6.2.4"
|
|
1477
1633
|
},
|
|
1478
1634
|
devDependencies: {
|
|
@@ -1499,12 +1655,13 @@ var REQUIRED_SCOPE = "tinycloud:mcp";
|
|
|
1499
1655
|
function createHostedMcpApp(config) {
|
|
1500
1656
|
const resourceUrl = canonicalResourceUrl(config.publicUrl);
|
|
1501
1657
|
const pluginUrl = new URL("./chatgpt/mcp", resourceUrl);
|
|
1502
|
-
const
|
|
1658
|
+
const statusFor = (resource) => config.introspection === void 0 ? void 0 : createTokenStatusChecker({ ...config.introspection, resourceUrl: resource.toString() });
|
|
1659
|
+
const hosted = createHostedSurface(config, resourceUrl, "hosted", statusFor(resourceUrl));
|
|
1503
1660
|
const plugin = createHostedSurface({
|
|
1504
1661
|
...config,
|
|
1505
1662
|
stateDir: (0, import_node_path3.join)(config.stateDir, "chatgpt"),
|
|
1506
1663
|
approvalPath: new URL("./connect", pluginUrl).pathname
|
|
1507
|
-
}, pluginUrl, "public");
|
|
1664
|
+
}, pluginUrl, "public", statusFor(pluginUrl));
|
|
1508
1665
|
const pluginPaths = {
|
|
1509
1666
|
[pluginUrl.pathname]: true,
|
|
1510
1667
|
[new URL("./connect", pluginUrl).pathname]: true,
|
|
@@ -1518,7 +1675,7 @@ function createHostedMcpApp(config) {
|
|
|
1518
1675
|
}
|
|
1519
1676
|
};
|
|
1520
1677
|
}
|
|
1521
|
-
function createHostedSurface(config, resourceUrl, surface) {
|
|
1678
|
+
function createHostedSurface(config, resourceUrl, surface, tokenStatus) {
|
|
1522
1679
|
const connectPath = config.approvalPath ?? "/connect";
|
|
1523
1680
|
const callbackPath = `${connectPath}/callback`;
|
|
1524
1681
|
const issuer = String(config.oauthMetadata.issuer);
|
|
@@ -1527,7 +1684,8 @@ function createHostedSurface(config, resourceUrl, surface) {
|
|
|
1527
1684
|
issuer,
|
|
1528
1685
|
audience: resourceUrl.toString(),
|
|
1529
1686
|
jwksUri,
|
|
1530
|
-
requiredScope: REQUIRED_SCOPE
|
|
1687
|
+
requiredScope: REQUIRED_SCOPE,
|
|
1688
|
+
...tokenStatus === void 0 ? {} : { tokenStatus }
|
|
1531
1689
|
});
|
|
1532
1690
|
const resourceMetadataUrl = (0, import_server4.getOAuthProtectedResourceMetadataUrl)(resourceUrl);
|
|
1533
1691
|
const authenticate = (0, import_server4.requireBearerAuth)({
|
|
@@ -1779,7 +1937,13 @@ async function main() {
|
|
|
1779
1937
|
approvalTtlSeconds: positiveInteger(process.env.TC_MCP_APPROVAL_TTL_SECONDS, 300),
|
|
1780
1938
|
delegationExpiry: process.env.TC_MCP_DELEGATION_EXPIRY ?? "1h",
|
|
1781
1939
|
allowedOrigins: commaList(process.env.TC_MCP_ALLOWED_ORIGINS),
|
|
1782
|
-
oauthMetadata
|
|
1940
|
+
oauthMetadata,
|
|
1941
|
+
introspection: {
|
|
1942
|
+
endpoint: requiredUrl("TC_MCP_INTROSPECTION_URL").toString(),
|
|
1943
|
+
clientId: requiredString("TC_MCP_INTROSPECTION_CLIENT_ID"),
|
|
1944
|
+
clientSecret: requiredString("TC_MCP_INTROSPECTION_CLIENT_SECRET"),
|
|
1945
|
+
authMethod: introspectionAuthMethod(process.env.TC_MCP_INTROSPECTION_AUTH_METHOD)
|
|
1946
|
+
}
|
|
1783
1947
|
});
|
|
1784
1948
|
const nodeHandler = (0, import_node.toNodeHandler)(app);
|
|
1785
1949
|
const server = (0, import_node_http.createServer)(nodeHandler);
|
|
@@ -1812,6 +1976,11 @@ function requiredAbsolutePath(name) {
|
|
|
1812
1976
|
if (!(0, import_node_path4.isAbsolute)(value)) throw new Error(`${name} must be an absolute path.`);
|
|
1813
1977
|
return (0, import_node_path4.resolve)(value);
|
|
1814
1978
|
}
|
|
1979
|
+
function introspectionAuthMethod(value) {
|
|
1980
|
+
if (value === void 0 || value === "basic") return "basic";
|
|
1981
|
+
if (value === "post") return "post";
|
|
1982
|
+
throw new Error("TC_MCP_INTROSPECTION_AUTH_METHOD must be basic or post.");
|
|
1983
|
+
}
|
|
1815
1984
|
function positiveInteger(value, fallback) {
|
|
1816
1985
|
if (value === void 0) return fallback;
|
|
1817
1986
|
const parsed = Number(value);
|