toolcraft 0.0.141 → 0.0.143
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/composition.json +3 -3
- package/dist/composition.json +3 -3
- package/dist/http-hosted-oauth.compile-check.d.ts +1 -0
- package/dist/http-hosted-oauth.compile-check.js +54 -0
- package/dist/http-hosted-oauth.d.ts +121 -0
- package/dist/http-hosted-oauth.js +438 -0
- package/dist/http.d.ts +4 -2
- package/dist/http.js +56 -4
- package/node_modules/mcp-oauth-server/dist/index.d.ts +2 -0
- package/node_modules/mcp-oauth-server/dist/index.js +24 -4
- package/node_modules/tiny-http-mcp-server/dist/composition.json +1 -1
- package/node_modules/tiny-http-mcp-server/dist/express-middleware.js +1 -1
- package/node_modules/tiny-http-mcp-server/dist/http-server.d.ts +2 -0
- package/node_modules/tiny-http-mcp-server/dist/http-server.js +7 -1
- package/node_modules/tiny-http-mcp-server/dist/index.d.ts +1 -1
- package/node_modules/tiny-http-mcp-server/dist/server.d.ts +1 -1
- package/node_modules/tiny-http-mcp-server/package.json +1 -1
- package/node_modules/tiny-stdio-mcp-server/dist/composition.json +1 -1
- package/node_modules/toolcraft-schema/package.json +1 -1
- package/package.json +6 -2
package/composition.json
CHANGED
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
},
|
|
99
99
|
{
|
|
100
100
|
"name": "tiny-http-mcp-server",
|
|
101
|
-
"version": "0.1.
|
|
101
|
+
"version": "0.1.6",
|
|
102
102
|
"license": "MIT"
|
|
103
103
|
},
|
|
104
104
|
{
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
"name": "toolcraft",
|
|
116
|
-
"version": "0.0.
|
|
116
|
+
"version": "0.0.143",
|
|
117
117
|
"license": "MIT"
|
|
118
118
|
},
|
|
119
119
|
{
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
"name": "toolcraft-schema",
|
|
126
|
-
"version": "0.0.
|
|
126
|
+
"version": "0.0.143",
|
|
127
127
|
"license": "MIT"
|
|
128
128
|
},
|
|
129
129
|
{
|
package/dist/composition.json
CHANGED
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
},
|
|
99
99
|
{
|
|
100
100
|
"name": "tiny-http-mcp-server",
|
|
101
|
-
"version": "0.1.
|
|
101
|
+
"version": "0.1.6",
|
|
102
102
|
"license": "MIT"
|
|
103
103
|
},
|
|
104
104
|
{
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
"name": "toolcraft",
|
|
116
|
-
"version": "0.0.
|
|
116
|
+
"version": "0.0.143",
|
|
117
117
|
"license": "MIT"
|
|
118
118
|
},
|
|
119
119
|
{
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
"name": "toolcraft-schema",
|
|
126
|
-
"version": "0.0.
|
|
126
|
+
"version": "0.0.143",
|
|
127
127
|
"license": "MIT"
|
|
128
128
|
},
|
|
129
129
|
{
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { hostedOAuth } from "./http-hosted-oauth.js";
|
|
2
|
+
const ignoredHostedOAuth = hostedOAuth({
|
|
3
|
+
publicUrl: "https://calendar.example/mcp",
|
|
4
|
+
storage: ignoredStorage,
|
|
5
|
+
provider: {
|
|
6
|
+
name: "Skylight",
|
|
7
|
+
login: { fields: ["email", "password"] },
|
|
8
|
+
async connect({ email, password, signal }) {
|
|
9
|
+
const ignoredEmail = email;
|
|
10
|
+
const ignoredPassword = password;
|
|
11
|
+
const ignoredSignal = signal;
|
|
12
|
+
return {
|
|
13
|
+
accountId: ignoredEmail,
|
|
14
|
+
credential: `${ignoredPassword}:${ignoredSignal.aborted}`
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
services({ credentials, identity }) {
|
|
18
|
+
const ignoredSubject = identity.subject;
|
|
19
|
+
const ignoredClientId = identity.clientId;
|
|
20
|
+
const ignoredScopes = identity.scopes;
|
|
21
|
+
const ignoredResource = identity.resource;
|
|
22
|
+
const ignoredIssuer = identity.issuer;
|
|
23
|
+
void ignoredSubject;
|
|
24
|
+
void ignoredClientId;
|
|
25
|
+
void ignoredScopes;
|
|
26
|
+
void ignoredResource;
|
|
27
|
+
void ignoredIssuer;
|
|
28
|
+
return { skylight: { credential: () => credentials.read() } };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
void ignoredHostedOAuth;
|
|
33
|
+
const ignoredRedirectHostedOAuth = hostedOAuth({
|
|
34
|
+
publicUrl: "https://calendar.example/mcp",
|
|
35
|
+
storage: ignoredStorage,
|
|
36
|
+
provider: {
|
|
37
|
+
name: "Skylight",
|
|
38
|
+
services: ({ credentials }) => ({
|
|
39
|
+
skylight: { credential: () => credentials.read() }
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
advanced: {
|
|
43
|
+
interaction: {
|
|
44
|
+
paths: ["/oauth/skylight/callback"],
|
|
45
|
+
start: () => Response.redirect("https://login.example/authorize"),
|
|
46
|
+
handle: async ({ complete }) => complete({
|
|
47
|
+
transactionId: "transaction-id",
|
|
48
|
+
accountId: "account-id",
|
|
49
|
+
credential: "provider-session"
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
void ignoredRedirectHostedOAuth;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type JWK } from "jose";
|
|
2
|
+
import { type AuthorizationServerStore, type AuthorizationTransactionRecord, type OAuthAuthorizationServerSigningKey } from "mcp-oauth-server";
|
|
3
|
+
import type { HttpAdditionalRequestHandler, TinyHttpMcpServerOAuthOptions } from "tiny-http-mcp-server/server";
|
|
4
|
+
export type HostedOAuthLoginFieldName = "email" | "password" | "apiKey" | (string & {});
|
|
5
|
+
export interface HostedOAuthLoginField {
|
|
6
|
+
name: HostedOAuthLoginFieldName;
|
|
7
|
+
label?: string;
|
|
8
|
+
type?: "text" | "email" | "password";
|
|
9
|
+
}
|
|
10
|
+
export interface HostedOAuthCredentialStore<TCredential = unknown> {
|
|
11
|
+
get(subject: string): Promise<TCredential | undefined>;
|
|
12
|
+
set(subject: string, credential: TCredential): Promise<void>;
|
|
13
|
+
delete(subject: string): Promise<void>;
|
|
14
|
+
update(subject: string, update: (credential: TCredential) => Promise<TCredential> | TCredential): Promise<TCredential>;
|
|
15
|
+
}
|
|
16
|
+
export interface HostedOAuthStorageCapabilities {
|
|
17
|
+
durable: boolean;
|
|
18
|
+
encryptedCredentials: boolean;
|
|
19
|
+
stableKeys: boolean;
|
|
20
|
+
shared: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface HostedOAuthInteractionStore {
|
|
23
|
+
set(transaction: AuthorizationTransactionRecord): Promise<void>;
|
|
24
|
+
get(transactionId: string): Promise<AuthorizationTransactionRecord | undefined>;
|
|
25
|
+
delete(transactionId: string): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export interface HostedOAuthStorage<TCredential = unknown> {
|
|
28
|
+
authorizationServer: AuthorizationServerStore;
|
|
29
|
+
interactions: HostedOAuthInteractionStore;
|
|
30
|
+
credentials: HostedOAuthCredentialStore<TCredential>;
|
|
31
|
+
capabilities: HostedOAuthStorageCapabilities;
|
|
32
|
+
signingKey(): Promise<OAuthAuthorizationServerSigningKey>;
|
|
33
|
+
resolveSubject(providerName: string, accountId: string): Promise<string>;
|
|
34
|
+
cleanup?(now?: number): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export interface HostedOAuthCredentialAccess<TCredential = unknown> {
|
|
37
|
+
read(): Promise<TCredential>;
|
|
38
|
+
update(update: (credential: TCredential) => Promise<TCredential> | TCredential): Promise<TCredential>;
|
|
39
|
+
delete(): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
export interface HostedOAuthIdentity {
|
|
42
|
+
issuer: string;
|
|
43
|
+
subject: string;
|
|
44
|
+
clientId: string;
|
|
45
|
+
scopes: readonly string[];
|
|
46
|
+
resource: string;
|
|
47
|
+
}
|
|
48
|
+
export interface HostedOAuthProvider<TCredential = unknown, TServices extends object = object> {
|
|
49
|
+
name: string;
|
|
50
|
+
login?: {
|
|
51
|
+
fields: readonly (HostedOAuthLoginFieldName | HostedOAuthLoginField)[];
|
|
52
|
+
};
|
|
53
|
+
connect?(fields: Readonly<Record<string, string>> & {
|
|
54
|
+
signal: AbortSignal;
|
|
55
|
+
}): Promise<{
|
|
56
|
+
accountId: string;
|
|
57
|
+
credential: TCredential;
|
|
58
|
+
}>;
|
|
59
|
+
services(input: {
|
|
60
|
+
credentials: HostedOAuthCredentialAccess<TCredential>;
|
|
61
|
+
identity: HostedOAuthIdentity;
|
|
62
|
+
}): Promise<Partial<TServices>> | Partial<TServices>;
|
|
63
|
+
}
|
|
64
|
+
export interface HostedOAuthInteractionAdapter<TCredential = unknown> {
|
|
65
|
+
paths: readonly string[];
|
|
66
|
+
start(context: {
|
|
67
|
+
request: Request;
|
|
68
|
+
transaction: AuthorizationTransactionRecord;
|
|
69
|
+
}): Promise<Response> | Response;
|
|
70
|
+
handle(context: {
|
|
71
|
+
request: Request;
|
|
72
|
+
complete(input: {
|
|
73
|
+
transactionId: string;
|
|
74
|
+
accountId: string;
|
|
75
|
+
credential: TCredential;
|
|
76
|
+
}): Promise<Response>;
|
|
77
|
+
}): Promise<Response> | Response;
|
|
78
|
+
}
|
|
79
|
+
export interface HostedOAuthAdvancedOptions<TCredential = unknown> {
|
|
80
|
+
scopes?: readonly string[];
|
|
81
|
+
branding?: {
|
|
82
|
+
title?: string;
|
|
83
|
+
};
|
|
84
|
+
accessTokenTtlSeconds?: number;
|
|
85
|
+
authorizationCodeTtlSeconds?: number;
|
|
86
|
+
authorizationTransactionTtlSeconds?: number;
|
|
87
|
+
refreshTokenTtlSeconds?: number;
|
|
88
|
+
additionalPublicJwks?: readonly JWK[];
|
|
89
|
+
interaction?: HostedOAuthInteractionAdapter<TCredential>;
|
|
90
|
+
}
|
|
91
|
+
export interface HostedOAuthOptions<TCredential = unknown, TServices extends object = object> {
|
|
92
|
+
publicUrl: string;
|
|
93
|
+
storage: HostedOAuthStorage<TCredential>;
|
|
94
|
+
provider: HostedOAuthProvider<TCredential, TServices>;
|
|
95
|
+
advanced?: HostedOAuthAdvancedOptions<TCredential>;
|
|
96
|
+
}
|
|
97
|
+
export interface PreparedHostedOAuth {
|
|
98
|
+
publicUrl: URL;
|
|
99
|
+
issuer: URL;
|
|
100
|
+
scopes: readonly string[];
|
|
101
|
+
}
|
|
102
|
+
export interface HostedOAuthConfiguration<TCredential = unknown, TServices extends object = object> extends HostedOAuthOptions<TCredential, TServices> {
|
|
103
|
+
readonly kind: "hosted";
|
|
104
|
+
prepare(options?: {
|
|
105
|
+
production?: boolean;
|
|
106
|
+
}): Promise<PreparedHostedOAuth>;
|
|
107
|
+
}
|
|
108
|
+
export declare function isHostedOAuthConfiguration(value: unknown): value is HostedOAuthConfiguration<unknown, object>;
|
|
109
|
+
export declare function hostedOAuth<TCredential = unknown, TServices extends object = object>(options: HostedOAuthOptions<TCredential, TServices>): HostedOAuthConfiguration<TCredential, TServices>;
|
|
110
|
+
export declare class HostedOAuthLoginError extends Error {
|
|
111
|
+
constructor(message: string);
|
|
112
|
+
}
|
|
113
|
+
export interface HostedOAuthRuntime<TServices extends object = object> {
|
|
114
|
+
mcpPath: string;
|
|
115
|
+
oauth: TinyHttpMcpServerOAuthOptions;
|
|
116
|
+
requestHandler: HttpAdditionalRequestHandler;
|
|
117
|
+
requestServices(identity: HostedOAuthIdentity): Promise<Partial<TServices>>;
|
|
118
|
+
}
|
|
119
|
+
export declare function createInMemoryHostedOAuthStorage<TCredential = unknown>(options: {
|
|
120
|
+
development: true;
|
|
121
|
+
}): HostedOAuthStorage<TCredential>;
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { createHash, createHmac, generateKeyPairSync, randomBytes } from "node:crypto";
|
|
2
|
+
import { exportJWK } from "jose";
|
|
3
|
+
import { createAuthorizationInteractionSecurity, createInMemoryAuthorizationServerStore, createOAuthAuthorizationServer, verifyAuthorizationInteractionCsrf } from "mcp-oauth-server";
|
|
4
|
+
export function isHostedOAuthConfiguration(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && "kind" in value && value.kind === "hosted";
|
|
6
|
+
}
|
|
7
|
+
function normalizePublicUrl(value) {
|
|
8
|
+
const url = new URL(value);
|
|
9
|
+
if (url.hash.length > 0 || url.search.length > 0) {
|
|
10
|
+
throw new Error("hosted OAuth publicUrl must not contain a query or fragment.");
|
|
11
|
+
}
|
|
12
|
+
if (url.username.length > 0 || url.password.length > 0) {
|
|
13
|
+
throw new Error("hosted OAuth publicUrl must not contain credentials.");
|
|
14
|
+
}
|
|
15
|
+
if (url.pathname === "/" || url.pathname.endsWith("/")) {
|
|
16
|
+
throw new Error("hosted OAuth publicUrl must contain a non-root path without a trailing slash.");
|
|
17
|
+
}
|
|
18
|
+
return url;
|
|
19
|
+
}
|
|
20
|
+
function configurationErrors(config, production) {
|
|
21
|
+
const errors = [];
|
|
22
|
+
const scopes = config.advanced?.scopes ?? ["mcp", "offline_access"];
|
|
23
|
+
if (!scopes.includes("mcp"))
|
|
24
|
+
errors.push("scopes containing required mcp scope");
|
|
25
|
+
if (scopes.some((scope) => scope.length === 0 || scope.trim() !== scope || scope.includes(" "))) {
|
|
26
|
+
errors.push("valid space-free scope names");
|
|
27
|
+
}
|
|
28
|
+
if (production) {
|
|
29
|
+
if (normalizePublicUrl(config.publicUrl).protocol !== "https:")
|
|
30
|
+
errors.push("HTTPS publicUrl");
|
|
31
|
+
if (!config.storage.capabilities.durable)
|
|
32
|
+
errors.push("durable storage");
|
|
33
|
+
if (!config.storage.capabilities.encryptedCredentials)
|
|
34
|
+
errors.push("encrypted credentials");
|
|
35
|
+
if (!config.storage.capabilities.stableKeys)
|
|
36
|
+
errors.push("stable signing and subject keys");
|
|
37
|
+
}
|
|
38
|
+
return errors;
|
|
39
|
+
}
|
|
40
|
+
export function hostedOAuth(options) {
|
|
41
|
+
if (options.provider.name.trim().length === 0)
|
|
42
|
+
throw new Error("provider.name is required.");
|
|
43
|
+
if (options.advanced?.interaction === undefined &&
|
|
44
|
+
(options.provider.login === undefined || options.provider.connect === undefined)) {
|
|
45
|
+
throw new Error("provider.login and provider.connect are required without an advanced interaction.");
|
|
46
|
+
}
|
|
47
|
+
if (options.provider.login !== undefined && options.provider.login.fields.length === 0) {
|
|
48
|
+
throw new Error("provider.login.fields must contain at least one field.");
|
|
49
|
+
}
|
|
50
|
+
const fieldNames = (options.provider.login?.fields ?? []).map((field) => typeof field === "string" ? field : field.name);
|
|
51
|
+
if (new Set(fieldNames).size !== fieldNames.length ||
|
|
52
|
+
fieldNames.some((name) => name.length === 0 || name === "signal" || name === "csrf" || name === "transaction")) {
|
|
53
|
+
throw new Error("provider.login.fields must have unique, non-reserved names.");
|
|
54
|
+
}
|
|
55
|
+
if (options.advanced?.interaction !== undefined &&
|
|
56
|
+
(options.advanced.interaction.paths.length === 0 ||
|
|
57
|
+
new Set(options.advanced.interaction.paths).size !==
|
|
58
|
+
options.advanced.interaction.paths.length ||
|
|
59
|
+
options.advanced.interaction.paths.some((path) => !path.startsWith("/") ||
|
|
60
|
+
[
|
|
61
|
+
"/healthz",
|
|
62
|
+
"/oauth/connect",
|
|
63
|
+
"/authorize",
|
|
64
|
+
"/register",
|
|
65
|
+
"/token",
|
|
66
|
+
"/revoke",
|
|
67
|
+
"/.well-known/oauth-authorization-server",
|
|
68
|
+
"/.well-known/jwks.json",
|
|
69
|
+
normalizePublicUrl(options.publicUrl).pathname
|
|
70
|
+
].includes(path)))) {
|
|
71
|
+
throw new Error("advanced.interaction.paths must contain unique, non-reserved absolute paths.");
|
|
72
|
+
}
|
|
73
|
+
normalizePublicUrl(options.publicUrl);
|
|
74
|
+
return {
|
|
75
|
+
...options,
|
|
76
|
+
kind: "hosted",
|
|
77
|
+
async prepare({ production = process.env.NODE_ENV === "production" } = {}) {
|
|
78
|
+
const errors = configurationErrors(this, production);
|
|
79
|
+
if (errors.length > 0) {
|
|
80
|
+
throw new Error(`Hosted OAuth configuration requires: ${errors.join(", ")}.`);
|
|
81
|
+
}
|
|
82
|
+
const publicUrl = normalizePublicUrl(this.publicUrl);
|
|
83
|
+
return {
|
|
84
|
+
publicUrl,
|
|
85
|
+
issuer: new URL(publicUrl.origin),
|
|
86
|
+
scopes: this.advanced?.scopes ?? ["mcp", "offline_access"]
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export class HostedOAuthLoginError extends Error {
|
|
92
|
+
constructor(message) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "HostedOAuthLoginError";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function escapeHtml(value) {
|
|
98
|
+
return value
|
|
99
|
+
.replaceAll("&", "&")
|
|
100
|
+
.replaceAll("<", "<")
|
|
101
|
+
.replaceAll(">", ">")
|
|
102
|
+
.replaceAll('"', """)
|
|
103
|
+
.replaceAll("'", "'");
|
|
104
|
+
}
|
|
105
|
+
function loginField(field) {
|
|
106
|
+
if (typeof field !== "string")
|
|
107
|
+
return field;
|
|
108
|
+
return {
|
|
109
|
+
name: field,
|
|
110
|
+
label: field === "apiKey" ? "API key" : `${field[0]?.toUpperCase() ?? ""}${field.slice(1)}`,
|
|
111
|
+
type: field === "password" || field === "apiKey" ? "password" : field === "email" ? "email" : "text"
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function renderLogin(providerName, fields, transaction, csrfToken, error) {
|
|
115
|
+
const controls = fields
|
|
116
|
+
.map((field) => {
|
|
117
|
+
const name = escapeHtml(field.name);
|
|
118
|
+
return `<label>${escapeHtml(field.label ?? field.name)}<input name="${name}" type="${field.type ?? "text"}" required autocomplete="${field.type === "password" ? "current-password" : "off"}"></label>`;
|
|
119
|
+
})
|
|
120
|
+
.join("");
|
|
121
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Connect ${escapeHtml(providerName)}</title><style>body{font:16px system-ui;max-width:28rem;margin:10vh auto;padding:1rem;color:#171717}form{display:grid;gap:1rem}label{display:grid;gap:.35rem}input,button{font:inherit;padding:.7rem}button{cursor:pointer}${error === undefined ? "" : ".error{color:#b42318}"}</style></head><body><h1>Connect ${escapeHtml(providerName)}</h1>${error === undefined ? "" : `<p class="error">${escapeHtml(error)}</p>`}<form method="post" action="/oauth/connect"><input type="hidden" name="transaction" value="${escapeHtml(transaction.id)}"><input type="hidden" name="csrf" value="${escapeHtml(csrfToken)}">${controls}<button type="submit">Connect</button></form></body></html>`;
|
|
122
|
+
}
|
|
123
|
+
function loginContentSecurityPolicy(transaction) {
|
|
124
|
+
const callbackOrigin = new URL(transaction.redirectUri).origin;
|
|
125
|
+
return `default-src 'none'; style-src 'unsafe-inline'; form-action 'self' ${callbackOrigin}; base-uri 'none'; frame-ancestors 'none'`;
|
|
126
|
+
}
|
|
127
|
+
function interactionCookieName(transactionId) {
|
|
128
|
+
const suffix = createHash("sha256").update(transactionId).digest("base64url").slice(0, 22);
|
|
129
|
+
return `__Host-mcp_oauth_csrf_${suffix}`;
|
|
130
|
+
}
|
|
131
|
+
function writeWebResponse(response, webResponse) {
|
|
132
|
+
webResponse.headers.forEach((value, name) => response.setHeader(name, value));
|
|
133
|
+
response.statusCode = webResponse.status;
|
|
134
|
+
return webResponse.arrayBuffer().then((body) => {
|
|
135
|
+
response.end(Buffer.from(body));
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async function readBody(request, maxBytes = 65_536) {
|
|
139
|
+
const chunks = [];
|
|
140
|
+
let size = 0;
|
|
141
|
+
for await (const chunk of request) {
|
|
142
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
143
|
+
size += buffer.length;
|
|
144
|
+
if (size > maxBytes)
|
|
145
|
+
throw new Error("Request body is too large.");
|
|
146
|
+
chunks.push(buffer);
|
|
147
|
+
}
|
|
148
|
+
return Buffer.concat(chunks);
|
|
149
|
+
}
|
|
150
|
+
function toWebRequest(request, issuer, body) {
|
|
151
|
+
const headers = new Headers();
|
|
152
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
153
|
+
if (value !== undefined)
|
|
154
|
+
headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
155
|
+
}
|
|
156
|
+
return new Request(new URL(request.url ?? "/", issuer), {
|
|
157
|
+
method: request.method,
|
|
158
|
+
headers,
|
|
159
|
+
...(body !== undefined && body.length > 0 ? { body: body.toString("utf8") } : {})
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function credentialsFor(storage, subject) {
|
|
163
|
+
return {
|
|
164
|
+
async read() {
|
|
165
|
+
const credential = await storage.credentials.get(subject);
|
|
166
|
+
if (credential === undefined)
|
|
167
|
+
throw new Error("Provider credential is missing; reconnect required.");
|
|
168
|
+
return credential;
|
|
169
|
+
},
|
|
170
|
+
update: (update) => storage.credentials.update(subject, update),
|
|
171
|
+
delete: () => storage.credentials.delete(subject)
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** @internal */
|
|
175
|
+
export async function prepareHostedOAuthRuntime(config) {
|
|
176
|
+
const prepared = await config.prepare();
|
|
177
|
+
const fields = (config.provider.login?.fields ?? []).map(loginField);
|
|
178
|
+
const customInteraction = config.advanced?.interaction;
|
|
179
|
+
const displayName = config.advanced?.branding?.title ?? config.provider.name;
|
|
180
|
+
const interaction = {
|
|
181
|
+
async start({ request, transaction }) {
|
|
182
|
+
await config.storage.interactions.set(transaction);
|
|
183
|
+
if (customInteraction !== undefined) {
|
|
184
|
+
return customInteraction.start({ request, transaction });
|
|
185
|
+
}
|
|
186
|
+
const security = createAuthorizationInteractionSecurity({
|
|
187
|
+
cookieName: interactionCookieName(transaction.id)
|
|
188
|
+
});
|
|
189
|
+
return new Response(renderLogin(displayName, fields, transaction, security.csrfToken), {
|
|
190
|
+
status: 200,
|
|
191
|
+
headers: {
|
|
192
|
+
"content-type": "text/html; charset=utf-8",
|
|
193
|
+
"content-security-policy": loginContentSecurityPolicy(transaction),
|
|
194
|
+
"cache-control": "no-store",
|
|
195
|
+
"referrer-policy": "no-referrer",
|
|
196
|
+
"x-content-type-options": "nosniff",
|
|
197
|
+
"set-cookie": security.setCookie
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
await config.storage.cleanup?.();
|
|
203
|
+
const authorizationServer = createOAuthAuthorizationServer({
|
|
204
|
+
issuer: prepared.issuer.href,
|
|
205
|
+
resources: [prepared.publicUrl.href],
|
|
206
|
+
scopesSupported: prepared.scopes,
|
|
207
|
+
defaultScopes: prepared.scopes,
|
|
208
|
+
signingKey: await config.storage.signingKey(),
|
|
209
|
+
additionalPublicJwks: config.advanced?.additionalPublicJwks,
|
|
210
|
+
store: config.storage.authorizationServer,
|
|
211
|
+
interaction,
|
|
212
|
+
accessTokenTtlSeconds: config.advanced?.accessTokenTtlSeconds,
|
|
213
|
+
authorizationCodeTtlSeconds: config.advanced?.authorizationCodeTtlSeconds,
|
|
214
|
+
authorizationTransactionTtlSeconds: config.advanced?.authorizationTransactionTtlSeconds,
|
|
215
|
+
refreshTokenTtlSeconds: config.advanced?.refreshTokenTtlSeconds
|
|
216
|
+
});
|
|
217
|
+
const requestHandler = async (request, response) => {
|
|
218
|
+
const url = new URL(request.url ?? "/", prepared.issuer);
|
|
219
|
+
if (request.method === "GET" && url.pathname === "/healthz") {
|
|
220
|
+
response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
221
|
+
response.end('{"ok":true}');
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
if (customInteraction?.paths.includes(url.pathname) === true) {
|
|
225
|
+
const body = request.method === "GET" || request.method === "HEAD" ? undefined : await readBody(request);
|
|
226
|
+
const handled = await customInteraction.handle({
|
|
227
|
+
request: toWebRequest(request, prepared.issuer, body),
|
|
228
|
+
async complete({ transactionId, accountId, credential }) {
|
|
229
|
+
const subject = await config.storage.resolveSubject(config.provider.name, accountId);
|
|
230
|
+
const completed = await authorizationServer.completeAuthorization({
|
|
231
|
+
transactionId,
|
|
232
|
+
subject
|
|
233
|
+
});
|
|
234
|
+
await config.storage.credentials.set(subject, credential);
|
|
235
|
+
await config.storage.interactions.delete(transactionId);
|
|
236
|
+
return new Response(null, {
|
|
237
|
+
status: 303,
|
|
238
|
+
headers: {
|
|
239
|
+
location: completed.redirectUrl.href,
|
|
240
|
+
"cache-control": "no-store",
|
|
241
|
+
"content-security-policy": "default-src 'none'",
|
|
242
|
+
"referrer-policy": "no-referrer"
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
await writeWebResponse(response, handled);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
if (request.method === "POST" && url.pathname === "/oauth/connect") {
|
|
251
|
+
const body = new URLSearchParams((await readBody(request)).toString("utf8"));
|
|
252
|
+
const transactionId = body.get("transaction") ?? "";
|
|
253
|
+
const transaction = await config.storage.interactions.get(transactionId);
|
|
254
|
+
const csrf = body.get("csrf") ?? "";
|
|
255
|
+
if (transaction === undefined ||
|
|
256
|
+
!verifyAuthorizationInteractionCsrf({
|
|
257
|
+
cookieHeader: request.headers.cookie ?? null,
|
|
258
|
+
submittedToken: csrf,
|
|
259
|
+
cookieName: interactionCookieName(transactionId)
|
|
260
|
+
}) ||
|
|
261
|
+
transaction.expiresAt <= Date.now()) {
|
|
262
|
+
response.writeHead(400, {
|
|
263
|
+
"content-type": "text/plain; charset=utf-8",
|
|
264
|
+
"cache-control": "no-store"
|
|
265
|
+
});
|
|
266
|
+
response.end("This connection has expired or was already used. Restart the connection.");
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
const controller = new AbortController();
|
|
270
|
+
request.once("aborted", () => controller.abort());
|
|
271
|
+
const values = Object.assign(Object.create(null), {
|
|
272
|
+
signal: controller.signal
|
|
273
|
+
});
|
|
274
|
+
for (const field of fields)
|
|
275
|
+
values[field.name] = body.get(field.name) ?? "";
|
|
276
|
+
try {
|
|
277
|
+
const connect = config.provider.connect;
|
|
278
|
+
if (connect === undefined)
|
|
279
|
+
throw new Error("Provider form connection is not configured.");
|
|
280
|
+
const connected = await connect(values);
|
|
281
|
+
if (connected.accountId.trim().length === 0)
|
|
282
|
+
throw new Error("Provider returned an empty accountId.");
|
|
283
|
+
const subject = await config.storage.resolveSubject(config.provider.name, connected.accountId);
|
|
284
|
+
const completed = await authorizationServer.completeAuthorization({
|
|
285
|
+
transactionId,
|
|
286
|
+
subject
|
|
287
|
+
});
|
|
288
|
+
await config.storage.credentials.set(subject, connected.credential);
|
|
289
|
+
await config.storage.interactions.delete(transactionId);
|
|
290
|
+
response.writeHead(303, {
|
|
291
|
+
location: completed.redirectUrl.href,
|
|
292
|
+
"cache-control": "no-store",
|
|
293
|
+
"content-security-policy": "default-src 'none'",
|
|
294
|
+
"referrer-policy": "no-referrer"
|
|
295
|
+
});
|
|
296
|
+
response.end();
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
const safeError = error instanceof HostedOAuthLoginError
|
|
300
|
+
? error.message
|
|
301
|
+
: "Sign-in failed. Please try again.";
|
|
302
|
+
response.writeHead(400, {
|
|
303
|
+
"content-type": "text/html; charset=utf-8",
|
|
304
|
+
"content-security-policy": loginContentSecurityPolicy(transaction),
|
|
305
|
+
"cache-control": "no-store",
|
|
306
|
+
"referrer-policy": "no-referrer"
|
|
307
|
+
});
|
|
308
|
+
response.end(renderLogin(displayName, fields, transaction, csrf, safeError));
|
|
309
|
+
}
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
const oauthPaths = new Set([
|
|
313
|
+
"/.well-known/oauth-authorization-server",
|
|
314
|
+
"/.well-known/jwks.json",
|
|
315
|
+
"/authorize",
|
|
316
|
+
"/register",
|
|
317
|
+
"/token",
|
|
318
|
+
"/revoke"
|
|
319
|
+
]);
|
|
320
|
+
if (!oauthPaths.has(url.pathname))
|
|
321
|
+
return false;
|
|
322
|
+
const body = request.method === "POST" ? await readBody(request) : undefined;
|
|
323
|
+
await writeWebResponse(response, await authorizationServer.handle(toWebRequest(request, prepared.issuer, body)));
|
|
324
|
+
return true;
|
|
325
|
+
};
|
|
326
|
+
return {
|
|
327
|
+
mcpPath: prepared.publicUrl.pathname,
|
|
328
|
+
oauth: {
|
|
329
|
+
resource: prepared.publicUrl.href,
|
|
330
|
+
authorizationServers: [authorizationServer.issuer],
|
|
331
|
+
requiredScopes: ["mcp"],
|
|
332
|
+
scopesSupported: [...prepared.scopes],
|
|
333
|
+
verifier: {
|
|
334
|
+
async verify(input) {
|
|
335
|
+
const verified = await authorizationServer.verifyAccessToken(input.token, prepared.publicUrl.href);
|
|
336
|
+
return {
|
|
337
|
+
token: input.token,
|
|
338
|
+
issuer: authorizationServer.issuer,
|
|
339
|
+
audience: [verified.resource],
|
|
340
|
+
scopes: [...verified.scopes],
|
|
341
|
+
expiresAt: verified.expiresAt,
|
|
342
|
+
claims: { sub: verified.subject, client_id: verified.clientId, jti: verified.tokenId },
|
|
343
|
+
subject: verified.subject,
|
|
344
|
+
clientId: verified.clientId
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
requestHandler,
|
|
350
|
+
async requestServices(identity) {
|
|
351
|
+
const credentials = credentialsFor(config.storage, identity.subject);
|
|
352
|
+
await credentials.read();
|
|
353
|
+
return config.provider.services({ credentials, identity });
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
export function createInMemoryHostedOAuthStorage(options) {
|
|
358
|
+
if (options.development !== true) {
|
|
359
|
+
throw new Error("In-memory hosted OAuth storage requires explicit development mode.");
|
|
360
|
+
}
|
|
361
|
+
const credentials = new Map();
|
|
362
|
+
const interactions = new Map();
|
|
363
|
+
const updates = new Map();
|
|
364
|
+
const subjectSalt = randomBytes(32);
|
|
365
|
+
let signingKeyPromise;
|
|
366
|
+
return {
|
|
367
|
+
authorizationServer: createInMemoryAuthorizationServerStore(),
|
|
368
|
+
interactions: {
|
|
369
|
+
async set(transaction) {
|
|
370
|
+
interactions.set(transaction.id, structuredClone(transaction));
|
|
371
|
+
},
|
|
372
|
+
async get(transactionId) {
|
|
373
|
+
const transaction = interactions.get(transactionId);
|
|
374
|
+
return transaction === undefined ? undefined : structuredClone(transaction);
|
|
375
|
+
},
|
|
376
|
+
async delete(transactionId) {
|
|
377
|
+
interactions.delete(transactionId);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
capabilities: {
|
|
381
|
+
durable: false,
|
|
382
|
+
encryptedCredentials: false,
|
|
383
|
+
stableKeys: false,
|
|
384
|
+
shared: false
|
|
385
|
+
},
|
|
386
|
+
credentials: {
|
|
387
|
+
async get(subject) {
|
|
388
|
+
return credentials.get(subject);
|
|
389
|
+
},
|
|
390
|
+
async set(subject, credential) {
|
|
391
|
+
credentials.set(subject, credential);
|
|
392
|
+
},
|
|
393
|
+
async delete(subject) {
|
|
394
|
+
credentials.delete(subject);
|
|
395
|
+
},
|
|
396
|
+
async update(subject, update) {
|
|
397
|
+
const previous = updates.get(subject) ?? Promise.resolve();
|
|
398
|
+
const next = previous.then(async () => {
|
|
399
|
+
const current = credentials.get(subject);
|
|
400
|
+
if (current === undefined)
|
|
401
|
+
throw new Error("Provider credential is missing; reconnect required.");
|
|
402
|
+
const replacement = await update(current);
|
|
403
|
+
credentials.set(subject, replacement);
|
|
404
|
+
return replacement;
|
|
405
|
+
});
|
|
406
|
+
updates.set(subject, next);
|
|
407
|
+
try {
|
|
408
|
+
return await next;
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
if (updates.get(subject) === next)
|
|
412
|
+
updates.delete(subject);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
async signingKey() {
|
|
417
|
+
signingKeyPromise ??= (async () => {
|
|
418
|
+
const { privateKey, publicKey } = generateKeyPairSync("ec", {
|
|
419
|
+
namedCurve: "prime256v1"
|
|
420
|
+
});
|
|
421
|
+
return {
|
|
422
|
+
algorithm: "ES256",
|
|
423
|
+
keyId: randomBytes(16).toString("base64url"),
|
|
424
|
+
privateKey,
|
|
425
|
+
publicJwk: await exportJWK(publicKey)
|
|
426
|
+
};
|
|
427
|
+
})();
|
|
428
|
+
return await signingKeyPromise;
|
|
429
|
+
},
|
|
430
|
+
async resolveSubject(providerName, accountId) {
|
|
431
|
+
return createHmac("sha256", subjectSalt)
|
|
432
|
+
.update(providerName)
|
|
433
|
+
.update("\0")
|
|
434
|
+
.update(accountId)
|
|
435
|
+
.digest("base64url");
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}
|
package/dist/http.d.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import "./node-require-shim.js";
|
|
2
|
-
import { type HttpListenOptions, type HttpServer, type HttpServerHandle, type HttpToolContext, type HttpTransportOptions } from "tiny-http-mcp-server/server";
|
|
2
|
+
import { type HttpListenOptions, type HttpServer, type HttpServerHandle, type HttpToolContext, type HttpTransportOptions, type TinyHttpMcpServerOAuthOptions } from "tiny-http-mcp-server/server";
|
|
3
3
|
import type { Group } from "./index.js";
|
|
4
4
|
import type { OAuthAuthorizationServer } from "mcp-oauth-server";
|
|
5
5
|
import { type RunMCPOptions } from "./mcp.js";
|
|
6
|
+
import { type HostedOAuthConfiguration } from "./http-hosted-oauth.js";
|
|
6
7
|
export type ToolcraftHTTPContext = HttpToolContext;
|
|
7
8
|
export type ToolcraftHTTPServer = HttpServer;
|
|
8
9
|
export type ToolcraftHTTPServerHandle = HttpServerHandle;
|
|
9
|
-
type HTTPTransportControls = Omit<HttpTransportOptions, "name" | "version" | "validateToolArguments">;
|
|
10
|
+
type HTTPTransportControls = Omit<HttpTransportOptions, "name" | "version" | "validateToolArguments" | "oauth" | "requestHandler">;
|
|
10
11
|
type HTTPListenControls = Omit<HttpListenOptions, "port" | "hostname" | "path">;
|
|
11
12
|
export interface RunHTTPMCPOptions<TServices extends object = Record<string, unknown>> extends RunMCPOptions<TServices>, HTTPTransportControls, HTTPListenControls {
|
|
12
13
|
hostname?: string;
|
|
13
14
|
port?: number;
|
|
14
15
|
path?: string;
|
|
15
16
|
requestServices?(context: ToolcraftHTTPContext): Partial<TServices> | Promise<Partial<TServices>>;
|
|
17
|
+
oauth?: TinyHttpMcpServerOAuthOptions | HostedOAuthConfiguration<unknown, TServices>;
|
|
16
18
|
}
|
|
17
19
|
export interface HTTPMCPAuthorizationOptions {
|
|
18
20
|
authorizationServer: Pick<OAuthAuthorizationServer, "issuer" | "verifyAccessToken">;
|
package/dist/http.js
CHANGED
|
@@ -2,6 +2,7 @@ import "./node-require-shim.js";
|
|
|
2
2
|
import { createHttpServer } from "tiny-http-mcp-server/server";
|
|
3
3
|
import { createMCPServerForTransport } from "./mcp.js";
|
|
4
4
|
import { enableSourceMaps } from "./stack-trim.js";
|
|
5
|
+
import { isHostedOAuthConfiguration, prepareHostedOAuthRuntime } from "./http-hosted-oauth.js";
|
|
5
6
|
function toVerifiedAccessToken(token, issuer, verified) {
|
|
6
7
|
return {
|
|
7
8
|
token,
|
|
@@ -62,7 +63,8 @@ function createTransportOptions(options, serverOptions) {
|
|
|
62
63
|
requestIdGenerator: options.requestIdGenerator,
|
|
63
64
|
observability: options.observability,
|
|
64
65
|
trustedProxy: options.trustedProxy,
|
|
65
|
-
oauth: options.oauth
|
|
66
|
+
oauth: options.oauth,
|
|
67
|
+
requestHandler: options.requestHandler
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
70
|
function createListenOptions(options) {
|
|
@@ -77,20 +79,70 @@ function createListenOptions(options) {
|
|
|
77
79
|
};
|
|
78
80
|
}
|
|
79
81
|
export async function createHTTPMCPServer(roots, options) {
|
|
82
|
+
let resolvedOptions = options;
|
|
83
|
+
let hostedMcpPath;
|
|
84
|
+
if (isHostedOAuthConfiguration(options.oauth)) {
|
|
85
|
+
const runtime = await prepareHostedOAuthRuntime(options.oauth);
|
|
86
|
+
const applicationServices = options.requestServices;
|
|
87
|
+
hostedMcpPath = runtime.mcpPath;
|
|
88
|
+
resolvedOptions = {
|
|
89
|
+
...options,
|
|
90
|
+
oauth: runtime.oauth,
|
|
91
|
+
sessionIdGenerator: undefined,
|
|
92
|
+
enableJsonResponse: true,
|
|
93
|
+
requestServices: async (context) => {
|
|
94
|
+
const auth = context.auth;
|
|
95
|
+
const subject = auth?.subject;
|
|
96
|
+
if (auth === undefined || subject === undefined)
|
|
97
|
+
throw new Error("Hosted OAuth request is missing a verified subject.");
|
|
98
|
+
return {
|
|
99
|
+
...(applicationServices === undefined ? {} : await applicationServices(context)),
|
|
100
|
+
...(await runtime.requestServices({
|
|
101
|
+
issuer: auth.issuer,
|
|
102
|
+
subject,
|
|
103
|
+
clientId: auth.clientId,
|
|
104
|
+
scopes: auth.scopes,
|
|
105
|
+
resource: auth.resource.href
|
|
106
|
+
}))
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
requestHandler: runtime.requestHandler
|
|
110
|
+
};
|
|
111
|
+
}
|
|
80
112
|
let server;
|
|
81
|
-
await createMCPServerForTransport(roots,
|
|
113
|
+
await createMCPServerForTransport(roots, resolvedOptions, {
|
|
82
114
|
createServer(serverOptions) {
|
|
83
|
-
server = createHttpServer(createTransportOptions(
|
|
115
|
+
server = createHttpServer(createTransportOptions(resolvedOptions, serverOptions));
|
|
84
116
|
return server;
|
|
85
117
|
},
|
|
86
118
|
getRequestContext() {
|
|
87
119
|
return server?.getRequestContext();
|
|
88
120
|
},
|
|
89
|
-
requestServices:
|
|
121
|
+
requestServices: resolvedOptions.requestServices
|
|
90
122
|
});
|
|
91
123
|
if (server === undefined) {
|
|
92
124
|
throw new Error("Toolcraft HTTP MCP server was not created.");
|
|
93
125
|
}
|
|
126
|
+
if (hostedMcpPath !== undefined) {
|
|
127
|
+
const listenHttp = server.listenHttp.bind(server);
|
|
128
|
+
server.listenHttp = async (listenOptions = {}) => {
|
|
129
|
+
if (listenOptions.path !== undefined) {
|
|
130
|
+
const withLeadingSlash = listenOptions.path.startsWith("/")
|
|
131
|
+
? listenOptions.path
|
|
132
|
+
: `/${listenOptions.path}`;
|
|
133
|
+
const requestedPath = withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/")
|
|
134
|
+
? withLeadingSlash.slice(0, -1)
|
|
135
|
+
: withLeadingSlash;
|
|
136
|
+
if (requestedPath !== hostedMcpPath) {
|
|
137
|
+
throw new Error(`Hosted OAuth MCP path ${JSON.stringify(requestedPath)} conflicts with publicUrl path ${JSON.stringify(hostedMcpPath)}.`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return listenHttp({
|
|
141
|
+
...listenOptions,
|
|
142
|
+
path: hostedMcpPath
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
}
|
|
94
146
|
return server;
|
|
95
147
|
}
|
|
96
148
|
export async function runHTTPMCP(roots, options) {
|
|
@@ -98,6 +98,8 @@ export interface OAuthAuthorizationServerSigningKey {
|
|
|
98
98
|
export interface OAuthAuthorizationServerOptions {
|
|
99
99
|
issuer: string;
|
|
100
100
|
resources: readonly string[];
|
|
101
|
+
scopesSupported?: readonly string[];
|
|
102
|
+
defaultScopes?: readonly string[];
|
|
101
103
|
signingKey: OAuthAuthorizationServerSigningKey;
|
|
102
104
|
additionalPublicJwks?: readonly JWK[];
|
|
103
105
|
store: AuthorizationServerStore;
|
|
@@ -44,8 +44,7 @@ export function verifyAuthorizationInteractionCsrf(input) {
|
|
|
44
44
|
return false;
|
|
45
45
|
const cookieBuffer = Buffer.from(cookieValue);
|
|
46
46
|
const submittedBuffer = Buffer.from(input.submittedToken);
|
|
47
|
-
return (cookieBuffer.length === submittedBuffer.length &&
|
|
48
|
-
timingSafeEqual(cookieBuffer, submittedBuffer));
|
|
47
|
+
return (cookieBuffer.length === submittedBuffer.length && timingSafeEqual(cookieBuffer, submittedBuffer));
|
|
49
48
|
}
|
|
50
49
|
function isObject(value) {
|
|
51
50
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -70,7 +69,9 @@ function parseAbsoluteUrl(value, label) {
|
|
|
70
69
|
}
|
|
71
70
|
function validateIssuer(value) {
|
|
72
71
|
const issuer = new URL(parseAbsoluteUrl(value, "issuer"));
|
|
73
|
-
if (issuer.protocol !== "https:" &&
|
|
72
|
+
if (issuer.protocol !== "https:" &&
|
|
73
|
+
issuer.hostname !== "localhost" &&
|
|
74
|
+
issuer.hostname !== "127.0.0.1") {
|
|
74
75
|
throw new Error("issuer must use HTTPS unless it is loopback.");
|
|
75
76
|
}
|
|
76
77
|
if (issuer.pathname !== "/" || issuer.search.length > 0) {
|
|
@@ -247,6 +248,14 @@ export function createInMemoryAuthorizationServerStore() {
|
|
|
247
248
|
export function createOAuthAuthorizationServer(options) {
|
|
248
249
|
const issuer = validateIssuer(options.issuer);
|
|
249
250
|
const resources = validateResources(options.resources);
|
|
251
|
+
const scopesSupported = options.scopesSupported === undefined ? undefined : new Set(options.scopesSupported);
|
|
252
|
+
const defaultScopes = [...new Set(options.defaultScopes ?? [])];
|
|
253
|
+
if (defaultScopes.some((scope) => scope.length === 0 || /[\u0000-\u0020\u007f]/u.test(scope))) {
|
|
254
|
+
throw new Error("default scopes must contain valid scope names.");
|
|
255
|
+
}
|
|
256
|
+
if (scopesSupported !== undefined && defaultScopes.some((scope) => !scopesSupported.has(scope))) {
|
|
257
|
+
throw new Error("default scopes must be included in supported scopes.");
|
|
258
|
+
}
|
|
250
259
|
const now = options.now ?? Date.now;
|
|
251
260
|
const randomToken = options.randomToken ?? opaqueToken;
|
|
252
261
|
const accessTokenTtlMs = (options.accessTokenTtlSeconds ?? 300) * 1000;
|
|
@@ -363,13 +372,23 @@ export function createOAuthAuthorizationServer(options) {
|
|
|
363
372
|
if (url.searchParams.get("code_challenge_method") !== "S256") {
|
|
364
373
|
throw new OAuthProtocolError("invalid_request", "code_challenge_method must be S256.");
|
|
365
374
|
}
|
|
375
|
+
const requestedScopes = url.searchParams.has("scope")
|
|
376
|
+
? parseScopes(url.searchParams.get("scope"))
|
|
377
|
+
: [...defaultScopes];
|
|
378
|
+
if (defaultScopes.length > 0 && requestedScopes.length === 0) {
|
|
379
|
+
throw new OAuthProtocolError("invalid_scope", "scope must not be empty.");
|
|
380
|
+
}
|
|
381
|
+
if (scopesSupported !== undefined &&
|
|
382
|
+
requestedScopes.some((scope) => !scopesSupported.has(scope))) {
|
|
383
|
+
throw new OAuthProtocolError("invalid_scope", "One or more requested scopes are not supported.");
|
|
384
|
+
}
|
|
366
385
|
const transaction = {
|
|
367
386
|
id: randomToken(),
|
|
368
387
|
clientId,
|
|
369
388
|
redirectUri,
|
|
370
389
|
codeChallenge,
|
|
371
390
|
resource: requireResource(url.searchParams.get("resource")),
|
|
372
|
-
scopes:
|
|
391
|
+
scopes: requestedScopes,
|
|
373
392
|
...(url.searchParams.has("state") && { state: url.searchParams.get("state") ?? undefined }),
|
|
374
393
|
createdAt: now(),
|
|
375
394
|
expiresAt: now() + authorizationTransactionTtlMs
|
|
@@ -563,6 +582,7 @@ export function createOAuthAuthorizationServer(options) {
|
|
|
563
582
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
564
583
|
token_endpoint_auth_methods_supported: ["none"],
|
|
565
584
|
code_challenge_methods_supported: ["S256"],
|
|
585
|
+
...(scopesSupported === undefined ? {} : { scopes_supported: [...scopesSupported] }),
|
|
566
586
|
protected_resources: [...resources]
|
|
567
587
|
});
|
|
568
588
|
}
|
|
@@ -40,7 +40,7 @@ export function createProtectedResourceMetadataRouter(options) {
|
|
|
40
40
|
if (path === "/") {
|
|
41
41
|
return [PROTECTED_RESOURCE_METADATA_PATH];
|
|
42
42
|
}
|
|
43
|
-
return [`${PROTECTED_RESOURCE_METADATA_PATH}${path}`];
|
|
43
|
+
return [PROTECTED_RESOURCE_METADATA_PATH, `${PROTECTED_RESOURCE_METADATA_PATH}${path}`];
|
|
44
44
|
})();
|
|
45
45
|
return (req, res, next) => {
|
|
46
46
|
if (req.method !== "GET" || !metadataPaths.includes(req.path)) {
|
|
@@ -14,7 +14,9 @@ export interface TinyHttpMcpServerOAuthOptions extends ProtectedResourceMetadata
|
|
|
14
14
|
}
|
|
15
15
|
export type HttpTransportOptions = ServerOptions & StreamableHttpTransportOptions & {
|
|
16
16
|
oauth?: TinyHttpMcpServerOAuthOptions;
|
|
17
|
+
requestHandler?: HttpAdditionalRequestHandler;
|
|
17
18
|
};
|
|
19
|
+
export type HttpAdditionalRequestHandler = (request: IncomingMessage, response: ServerResponse) => boolean | Promise<boolean>;
|
|
18
20
|
export interface HttpListenOptions {
|
|
19
21
|
port?: number;
|
|
20
22
|
hostname?: string;
|
|
@@ -19,7 +19,7 @@ function getProtectedResourceMetadataPaths(path) {
|
|
|
19
19
|
if (path === "/") {
|
|
20
20
|
return [PROTECTED_RESOURCE_METADATA_PATH];
|
|
21
21
|
}
|
|
22
|
-
return [`${PROTECTED_RESOURCE_METADATA_PATH}${path}`];
|
|
22
|
+
return [PROTECTED_RESOURCE_METADATA_PATH, `${PROTECTED_RESOURCE_METADATA_PATH}${path}`];
|
|
23
23
|
}
|
|
24
24
|
function formatHostnameForUrl(hostname) {
|
|
25
25
|
if (hostname.includes(":") && !hostname.startsWith("[")) {
|
|
@@ -157,6 +157,9 @@ export function createHttpServer(options) {
|
|
|
157
157
|
const nodeServer = http.createServer(async (req, res) => {
|
|
158
158
|
const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
159
159
|
try {
|
|
160
|
+
if (options.requestHandler !== undefined && (await options.requestHandler(req, res))) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
160
163
|
if (protectedResourceMetadataBody !== undefined &&
|
|
161
164
|
req.method === "GET" &&
|
|
162
165
|
getProtectedResourceMetadataPaths(path).includes(requestUrl.pathname)) {
|
|
@@ -249,6 +252,9 @@ export function createHttpServer(options) {
|
|
|
249
252
|
};
|
|
250
253
|
};
|
|
251
254
|
httpServer.handleRequest = async (req, res) => {
|
|
255
|
+
if (options.requestHandler !== undefined && (await options.requestHandler(req, res))) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
252
258
|
const { baseUrl } = req;
|
|
253
259
|
const protectedResourcePath = typeof baseUrl === "string" && baseUrl.length > 0 ? baseUrl : "/mcp";
|
|
254
260
|
if (!(await authorizeHttpRequest(req, res, protectedResourcePath))) {
|
|
@@ -3,7 +3,7 @@ export type { Server, TypedSchema, ImageContent, AudioContent, EmbeddedResource,
|
|
|
3
3
|
export { createExpressMiddleware, createExpressOAuthHandlers, createProtectedResourceMetadataRouter } from "./express-middleware.js";
|
|
4
4
|
export type { CreateExpressOAuthHandlersOptions } from "./express-middleware.js";
|
|
5
5
|
export { createHttpServer, createProtectedResourceMetadataDocument } from "./http-server.js";
|
|
6
|
-
export type { HttpToolContext, HttpToolHandler, HttpListenOptions, HttpServer, HttpServerHandle, HttpTransportOptions, ProtectedResourceMetadataOptions, TinyHttpMcpServerOAuthOptions } from "./http-server.js";
|
|
6
|
+
export type { HttpToolContext, HttpToolHandler, HttpAdditionalRequestHandler, HttpListenOptions, HttpServer, HttpServerHandle, HttpTransportOptions, ProtectedResourceMetadataOptions, TinyHttpMcpServerOAuthOptions } from "./http-server.js";
|
|
7
7
|
export { TokenVerificationError } from "./auth.js";
|
|
8
8
|
export type { RequestAuthInfo, TokenVerifier, VerifiedAccessToken } from "./auth.js";
|
|
9
9
|
export { StreamableHttpTransport } from "./http-transport.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createHttpServer, createProtectedResourceMetadataDocument } from "./http-server.js";
|
|
2
|
-
export type { HttpToolContext, HttpToolHandler, HttpListenOptions, HttpServer, HttpServerHandle, HttpTransportOptions, ProtectedResourceMetadataOptions, TinyHttpMcpServerOAuthOptions } from "./http-server.js";
|
|
2
|
+
export type { HttpToolContext, HttpToolHandler, HttpAdditionalRequestHandler, HttpListenOptions, HttpServer, HttpServerHandle, HttpTransportOptions, ProtectedResourceMetadataOptions, TinyHttpMcpServerOAuthOptions } from "./http-server.js";
|
|
3
3
|
export { TokenVerificationError } from "./auth.js";
|
|
4
4
|
export type { RequestAuthInfo, TokenVerifier, VerifiedAccessToken } from "./auth.js";
|
|
5
5
|
export { StreamableHttpTransport } from "./http-transport.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolcraft",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.143",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -70,6 +70,10 @@
|
|
|
70
70
|
"types": "./dist/http.d.ts",
|
|
71
71
|
"import": "./dist/http.js"
|
|
72
72
|
},
|
|
73
|
+
"./http/hosted-oauth": {
|
|
74
|
+
"types": "./dist/http-hosted-oauth.d.ts",
|
|
75
|
+
"import": "./dist/http-hosted-oauth.js"
|
|
76
|
+
},
|
|
73
77
|
"./frontmatter": {
|
|
74
78
|
"types": "./dist/frontmatter.d.ts",
|
|
75
79
|
"import": "./dist/frontmatter.js"
|
|
@@ -154,7 +158,7 @@
|
|
|
154
158
|
"yaml"
|
|
155
159
|
],
|
|
156
160
|
"optionalDependencies": {
|
|
157
|
-
"toolcraft-schema": "0.0.
|
|
161
|
+
"toolcraft-schema": "0.0.143",
|
|
158
162
|
"toolcraft-design": "*",
|
|
159
163
|
"@poe-code/frontmatter": "*",
|
|
160
164
|
"@poe-code/agent-mcp-config": "*",
|