@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.d.cts
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { OAuthMetadata } from '@modelcontextprotocol/server';
|
|
2
2
|
|
|
3
|
+
interface TokenStatusConfig {
|
|
4
|
+
/** OpenKey's resource-server introspection endpoint (`TC_MCP_INTROSPECTION_URL`). */
|
|
5
|
+
readonly endpoint: string;
|
|
6
|
+
/** The resource URL (JWT audience) this checker serves; an active answer must name it. */
|
|
7
|
+
readonly resourceUrl: string;
|
|
8
|
+
readonly clientId: string;
|
|
9
|
+
readonly clientSecret: string;
|
|
10
|
+
/** How the confidential client authenticates: `client_secret_basic` or `client_secret_post`. */
|
|
11
|
+
readonly authMethod: "basic" | "post";
|
|
12
|
+
readonly timeoutMs?: number;
|
|
13
|
+
/** Test seams. */
|
|
14
|
+
readonly fetch?: typeof fetch;
|
|
15
|
+
readonly now?: () => number;
|
|
16
|
+
readonly log?: (message: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
3
19
|
interface RemoteStoreConfig {
|
|
4
20
|
readonly stateDir: string;
|
|
5
21
|
readonly stateSecret: string;
|
|
@@ -15,6 +31,12 @@ interface RemoteStoreConfig {
|
|
|
15
31
|
interface HostedMcpConfig extends RemoteStoreConfig {
|
|
16
32
|
readonly oauthMetadata: OAuthMetadata;
|
|
17
33
|
readonly allowedOrigins?: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* OpenKey token introspection client (`TC_MCP_INTROSPECTION_URL` and client
|
|
36
|
+
* credentials). The hosted server always sets this; without it only the
|
|
37
|
+
* signed token claims are checked.
|
|
38
|
+
*/
|
|
39
|
+
readonly introspection?: Omit<TokenStatusConfig, "resourceUrl">;
|
|
18
40
|
}
|
|
19
41
|
interface HostedMcpApp {
|
|
20
42
|
readonly fetch: (request: Request) => Promise<Response>;
|
package/dist/http.d.ts
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { OAuthMetadata } from '@modelcontextprotocol/server';
|
|
2
2
|
|
|
3
|
+
interface TokenStatusConfig {
|
|
4
|
+
/** OpenKey's resource-server introspection endpoint (`TC_MCP_INTROSPECTION_URL`). */
|
|
5
|
+
readonly endpoint: string;
|
|
6
|
+
/** The resource URL (JWT audience) this checker serves; an active answer must name it. */
|
|
7
|
+
readonly resourceUrl: string;
|
|
8
|
+
readonly clientId: string;
|
|
9
|
+
readonly clientSecret: string;
|
|
10
|
+
/** How the confidential client authenticates: `client_secret_basic` or `client_secret_post`. */
|
|
11
|
+
readonly authMethod: "basic" | "post";
|
|
12
|
+
readonly timeoutMs?: number;
|
|
13
|
+
/** Test seams. */
|
|
14
|
+
readonly fetch?: typeof fetch;
|
|
15
|
+
readonly now?: () => number;
|
|
16
|
+
readonly log?: (message: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
3
19
|
interface RemoteStoreConfig {
|
|
4
20
|
readonly stateDir: string;
|
|
5
21
|
readonly stateSecret: string;
|
|
@@ -15,6 +31,12 @@ interface RemoteStoreConfig {
|
|
|
15
31
|
interface HostedMcpConfig extends RemoteStoreConfig {
|
|
16
32
|
readonly oauthMetadata: OAuthMetadata;
|
|
17
33
|
readonly allowedOrigins?: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* OpenKey token introspection client (`TC_MCP_INTROSPECTION_URL` and client
|
|
36
|
+
* credentials). The hosted server always sets this; without it only the
|
|
37
|
+
* signed token claims are checked.
|
|
38
|
+
*/
|
|
39
|
+
readonly introspection?: Omit<TokenStatusConfig, "resourceUrl">;
|
|
18
40
|
}
|
|
19
41
|
interface HostedMcpApp {
|
|
20
42
|
readonly fetch: (request: Request) => Promise<Response>;
|
package/dist/http.js
CHANGED
|
@@ -28,7 +28,9 @@ function createOpenKeyTokenVerifier(config) {
|
|
|
28
28
|
issuer: config.issuer,
|
|
29
29
|
audience: config.audience
|
|
30
30
|
});
|
|
31
|
-
|
|
31
|
+
const info = authInfo(token, payload, config);
|
|
32
|
+
if (config.tokenStatus !== void 0) await requireUsableStatus(config.tokenStatus, token);
|
|
33
|
+
return info;
|
|
32
34
|
} catch (error) {
|
|
33
35
|
if (error instanceof errors.JOSEError) {
|
|
34
36
|
throw new OAuthError(OAuthErrorCode.InvalidToken, "The OpenKey access token is invalid.");
|
|
@@ -38,6 +40,16 @@ function createOpenKeyTokenVerifier(config) {
|
|
|
38
40
|
}
|
|
39
41
|
};
|
|
40
42
|
}
|
|
43
|
+
async function requireUsableStatus(checker, token) {
|
|
44
|
+
const status = await checker.tokenStatus(token);
|
|
45
|
+
if (status === "active") return;
|
|
46
|
+
if (status === "inactive") {
|
|
47
|
+
throw new OAuthError(OAuthErrorCode.InvalidToken, "The OpenKey access token is no longer active.");
|
|
48
|
+
}
|
|
49
|
+
if (!checker.toleratesUnknown(token)) {
|
|
50
|
+
throw new OAuthError(OAuthErrorCode.ServerError, "The OpenKey access token status is unavailable.");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
41
53
|
function authInfo(token, payload, config) {
|
|
42
54
|
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
|
|
43
55
|
throw new OAuthError(OAuthErrorCode.InvalidToken, "The OpenKey access token does not identify a subject.");
|
|
@@ -91,6 +103,150 @@ function firstString(...values) {
|
|
|
91
103
|
return values.find((value) => typeof value === "string" && value.length > 0);
|
|
92
104
|
}
|
|
93
105
|
|
|
106
|
+
// src/token-status.ts
|
|
107
|
+
import { createHash } from "crypto";
|
|
108
|
+
import { decodeJwt } from "jose";
|
|
109
|
+
var ACTIVE_CACHE_MS = 3e4;
|
|
110
|
+
var DEFAULT_TOKEN_LIFETIME_SECONDS = 300;
|
|
111
|
+
var DEFAULT_TIMEOUT_MS = 3e3;
|
|
112
|
+
var MAX_CACHED_TOKENS = 1e4;
|
|
113
|
+
function createTokenStatusChecker(config) {
|
|
114
|
+
const doFetch = config.fetch ?? fetch;
|
|
115
|
+
const clock = config.now ?? Date.now;
|
|
116
|
+
const log = config.log ?? ((message) => void process.stderr.write(`[tinycloud-mcp-http] ${message}
|
|
117
|
+
`));
|
|
118
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
119
|
+
const inactive = /* @__PURE__ */ new Map();
|
|
120
|
+
const active = /* @__PURE__ */ new Map();
|
|
121
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
122
|
+
const confirmedActive = /* @__PURE__ */ new Map();
|
|
123
|
+
let credentialsRefused = false;
|
|
124
|
+
async function introspect(token) {
|
|
125
|
+
const headers = {
|
|
126
|
+
accept: "application/json",
|
|
127
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
128
|
+
};
|
|
129
|
+
const body = new URLSearchParams({ token, token_type_hint: "access_token" });
|
|
130
|
+
if (config.authMethod === "basic") {
|
|
131
|
+
const encode = (value) => encodeURIComponent(value).replace(/%20/g, "+");
|
|
132
|
+
const credentials = `${encode(config.clientId)}:${encode(config.clientSecret)}`;
|
|
133
|
+
headers.authorization = `Basic ${Buffer.from(credentials).toString("base64")}`;
|
|
134
|
+
} else {
|
|
135
|
+
body.set("client_id", config.clientId);
|
|
136
|
+
body.set("client_secret", config.clientSecret);
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const response = await doFetch(config.endpoint, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers,
|
|
142
|
+
body,
|
|
143
|
+
redirect: "error",
|
|
144
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
145
|
+
});
|
|
146
|
+
if (response.status === 401) return "credentials";
|
|
147
|
+
if (response.status !== 200) return "unknown";
|
|
148
|
+
const result = await response.json();
|
|
149
|
+
if (typeof result !== "object" || result === null || !("active" in result)) return "unknown";
|
|
150
|
+
if (result.active === false) return "inactive";
|
|
151
|
+
if (result.active !== true) return "unknown";
|
|
152
|
+
return answerMatchesToken(Object.fromEntries(Object.entries(result)), token, config.resourceUrl) ? "active" : "mismatch";
|
|
153
|
+
} catch {
|
|
154
|
+
return "unknown";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function query(key, token) {
|
|
158
|
+
const outcome = await introspect(token);
|
|
159
|
+
if (outcome === "credentials") {
|
|
160
|
+
if (!credentialsRefused) {
|
|
161
|
+
log("OpenKey token introspection refused the MCP client credentials (401); token checks fail closed until it accepts them.");
|
|
162
|
+
}
|
|
163
|
+
credentialsRefused = true;
|
|
164
|
+
return "unknown";
|
|
165
|
+
}
|
|
166
|
+
if (outcome === "unknown") return outcome;
|
|
167
|
+
const now = clock();
|
|
168
|
+
prune(now);
|
|
169
|
+
if (outcome === "mismatch") {
|
|
170
|
+
inactive.set(key, tokenExpiryMs(token, now));
|
|
171
|
+
confirmedActive.delete(key);
|
|
172
|
+
return "inactive";
|
|
173
|
+
}
|
|
174
|
+
credentialsRefused = false;
|
|
175
|
+
if (outcome === "inactive") {
|
|
176
|
+
inactive.set(key, tokenExpiryMs(token, now));
|
|
177
|
+
confirmedActive.delete(key);
|
|
178
|
+
} else {
|
|
179
|
+
active.set(key, now + ACTIVE_CACHE_MS);
|
|
180
|
+
confirmedActive.delete(key);
|
|
181
|
+
confirmedActive.set(key, { at: now, expiresAt: tokenExpiryMs(token, now) });
|
|
182
|
+
}
|
|
183
|
+
return outcome;
|
|
184
|
+
}
|
|
185
|
+
function prune(now) {
|
|
186
|
+
for (const [key, until] of active) if (until <= now) active.delete(key);
|
|
187
|
+
for (const [key, until] of inactive) if (until <= now) inactive.delete(key);
|
|
188
|
+
for (const [key, entry] of confirmedActive) if (entry.expiresAt <= now) confirmedActive.delete(key);
|
|
189
|
+
for (const cache of [active, inactive, confirmedActive]) {
|
|
190
|
+
while (cache.size >= MAX_CACHED_TOKENS) {
|
|
191
|
+
const oldest = cache.keys().next().value;
|
|
192
|
+
if (oldest === void 0) break;
|
|
193
|
+
cache.delete(oldest);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
async tokenStatus(token) {
|
|
199
|
+
const key = createHash("sha256").update(token).digest("hex");
|
|
200
|
+
const now = clock();
|
|
201
|
+
const inactiveUntil = inactive.get(key);
|
|
202
|
+
if (inactiveUntil !== void 0 && inactiveUntil > now) return "inactive";
|
|
203
|
+
const activeUntil = active.get(key);
|
|
204
|
+
if (activeUntil !== void 0 && activeUntil > now) return "active";
|
|
205
|
+
const pending = inFlight.get(key);
|
|
206
|
+
if (pending !== void 0) return pending;
|
|
207
|
+
const asking = query(key, token).finally(() => inFlight.delete(key));
|
|
208
|
+
inFlight.set(key, asking);
|
|
209
|
+
return asking;
|
|
210
|
+
},
|
|
211
|
+
toleratesUnknown(token) {
|
|
212
|
+
if (credentialsRefused) return false;
|
|
213
|
+
const confirmed = confirmedActive.get(createHash("sha256").update(token).digest("hex"));
|
|
214
|
+
if (confirmed === void 0) return false;
|
|
215
|
+
const now = clock();
|
|
216
|
+
return confirmed.expiresAt > now && now - confirmed.at < tokenLifetimeSeconds(token) * 1e3;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function answerMatchesToken(answer, token, resourceUrl) {
|
|
221
|
+
const jwt = claims(token);
|
|
222
|
+
if (typeof jwt.sub === "string" && answer.sub !== jwt.sub) return false;
|
|
223
|
+
const jwtClient = [jwt.azp, jwt.client_id].find((value) => typeof value === "string" && value.length > 0);
|
|
224
|
+
if (jwtClient !== void 0 && answer.client_id !== jwtClient) return false;
|
|
225
|
+
if (jwt.aud === void 0) return true;
|
|
226
|
+
const answered = audienceList(answer.aud);
|
|
227
|
+
const signed = audienceList(jwt.aud);
|
|
228
|
+
return answered.length > 0 && answered.includes(resourceUrl) && answered.every((entry) => signed.includes(entry));
|
|
229
|
+
}
|
|
230
|
+
function audienceList(value) {
|
|
231
|
+
const list = typeof value === "string" ? [value] : value;
|
|
232
|
+
return Array.isArray(list) && list.every((entry) => typeof entry === "string") ? list : [];
|
|
233
|
+
}
|
|
234
|
+
function tokenExpiryMs(token, now) {
|
|
235
|
+
const exp = claims(token).exp;
|
|
236
|
+
return typeof exp === "number" ? exp * 1e3 : now + DEFAULT_TOKEN_LIFETIME_SECONDS * 1e3;
|
|
237
|
+
}
|
|
238
|
+
function tokenLifetimeSeconds(token) {
|
|
239
|
+
const { exp, iat } = claims(token);
|
|
240
|
+
return typeof exp === "number" && typeof iat === "number" && exp > iat ? exp - iat : DEFAULT_TOKEN_LIFETIME_SECONDS;
|
|
241
|
+
}
|
|
242
|
+
function claims(token) {
|
|
243
|
+
try {
|
|
244
|
+
return decodeJwt(token);
|
|
245
|
+
} catch {
|
|
246
|
+
return {};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
94
250
|
// src/logging.ts
|
|
95
251
|
function logToolCall(tool, code, startedAt, replayedGrants) {
|
|
96
252
|
process.stderr.write(`${JSON.stringify({
|
|
@@ -1377,7 +1533,7 @@ function outcomeCode(result) {
|
|
|
1377
1533
|
// package.json
|
|
1378
1534
|
var package_default = {
|
|
1379
1535
|
name: "@tinycloud/mcp",
|
|
1380
|
-
version: "0.8.3-beta.
|
|
1536
|
+
version: "0.8.3-beta.8",
|
|
1381
1537
|
description: "TinyCloud delegated operations MCP server",
|
|
1382
1538
|
keywords: [
|
|
1383
1539
|
"tinycloud",
|
|
@@ -1448,9 +1604,9 @@ var package_default = {
|
|
|
1448
1604
|
dependencies: {
|
|
1449
1605
|
"@modelcontextprotocol/node": "2.0.0-beta.5",
|
|
1450
1606
|
"@modelcontextprotocol/server": "2.0.0-beta.5",
|
|
1451
|
-
"@tinycloud/node-sdk": "3.8.0-beta.
|
|
1607
|
+
"@tinycloud/node-sdk": "3.8.0-beta.6",
|
|
1452
1608
|
"@tinycloud/node-sdk-wasm": "1.7.8",
|
|
1453
|
-
"@tinycloud/operations": "0.7.7-beta.
|
|
1609
|
+
"@tinycloud/operations": "0.7.7-beta.7",
|
|
1454
1610
|
jose: "6.2.4"
|
|
1455
1611
|
},
|
|
1456
1612
|
devDependencies: {
|
|
@@ -1477,12 +1633,13 @@ var REQUIRED_SCOPE = "tinycloud:mcp";
|
|
|
1477
1633
|
function createHostedMcpApp(config) {
|
|
1478
1634
|
const resourceUrl = canonicalResourceUrl(config.publicUrl);
|
|
1479
1635
|
const pluginUrl = new URL("./chatgpt/mcp", resourceUrl);
|
|
1480
|
-
const
|
|
1636
|
+
const statusFor = (resource) => config.introspection === void 0 ? void 0 : createTokenStatusChecker({ ...config.introspection, resourceUrl: resource.toString() });
|
|
1637
|
+
const hosted = createHostedSurface(config, resourceUrl, "hosted", statusFor(resourceUrl));
|
|
1481
1638
|
const plugin = createHostedSurface({
|
|
1482
1639
|
...config,
|
|
1483
1640
|
stateDir: join3(config.stateDir, "chatgpt"),
|
|
1484
1641
|
approvalPath: new URL("./connect", pluginUrl).pathname
|
|
1485
|
-
}, pluginUrl, "public");
|
|
1642
|
+
}, pluginUrl, "public", statusFor(pluginUrl));
|
|
1486
1643
|
const pluginPaths = {
|
|
1487
1644
|
[pluginUrl.pathname]: true,
|
|
1488
1645
|
[new URL("./connect", pluginUrl).pathname]: true,
|
|
@@ -1496,7 +1653,7 @@ function createHostedMcpApp(config) {
|
|
|
1496
1653
|
}
|
|
1497
1654
|
};
|
|
1498
1655
|
}
|
|
1499
|
-
function createHostedSurface(config, resourceUrl, surface) {
|
|
1656
|
+
function createHostedSurface(config, resourceUrl, surface, tokenStatus) {
|
|
1500
1657
|
const connectPath = config.approvalPath ?? "/connect";
|
|
1501
1658
|
const callbackPath = `${connectPath}/callback`;
|
|
1502
1659
|
const issuer = String(config.oauthMetadata.issuer);
|
|
@@ -1505,7 +1662,8 @@ function createHostedSurface(config, resourceUrl, surface) {
|
|
|
1505
1662
|
issuer,
|
|
1506
1663
|
audience: resourceUrl.toString(),
|
|
1507
1664
|
jwksUri,
|
|
1508
|
-
requiredScope: REQUIRED_SCOPE
|
|
1665
|
+
requiredScope: REQUIRED_SCOPE,
|
|
1666
|
+
...tokenStatus === void 0 ? {} : { tokenStatus }
|
|
1509
1667
|
});
|
|
1510
1668
|
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceUrl);
|
|
1511
1669
|
const authenticate = requireBearerAuth({
|