fix: report OAuth metadata deployment errors
This commit is contained in:
@@ -39,3 +39,5 @@ crafttable-mcp configure all
|
|||||||
可以使用 `--url`、`--client-id`、`--callback-port` 覆盖默认值,也可以设置 `CRAFTTABLE_MCP_URL`、`CRAFTTABLE_MCP_OAUTH_CLIENT_ID`、`CRAFTTABLE_MCP_OAUTH_CALLBACK_PORT` 环境变量。
|
可以使用 `--url`、`--client-id`、`--callback-port` 覆盖默认值,也可以设置 `CRAFTTABLE_MCP_URL`、`CRAFTTABLE_MCP_OAUTH_CLIENT_ID`、`CRAFTTABLE_MCP_OAUTH_CALLBACK_PORT` 环境变量。
|
||||||
|
|
||||||
远程地址必须使用 HTTPS。只有 `localhost`、`127.0.0.1` 和 `::1` 开发地址允许使用明文 HTTP。
|
远程地址必须使用 HTTPS。只有 `localhost`、`127.0.0.1` 和 `::1` 开发地址允许使用明文 HTTP。
|
||||||
|
|
||||||
|
如果 OAuth metadata 路径返回 HTML、404 或非 JSON 响应,CLI 会明确指出服务端 OAuth 尚未启用或反向代理配置错误,不再直接输出 JSON parser 异常。
|
||||||
|
|||||||
Vendored
+27
-8
@@ -592,6 +592,7 @@ async function connectRemote(options, tokenStore, discoveryStore, serviceToken =
|
|||||||
async function loginRemote(options, tokenStore, discoveryStore, input = { browser: true }) {
|
async function loginRemote(options, tokenStore, discoveryStore, input = { browser: true }) {
|
||||||
const writeLine = input.writeLine ?? ((value) => process.stderr.write(`${value}
|
const writeLine = input.writeLine ?? ((value) => process.stderr.write(`${value}
|
||||||
`));
|
`));
|
||||||
|
await requireOAuthProtectedResourceMetadata(options.url);
|
||||||
let authorizationUrl;
|
let authorizationUrl;
|
||||||
const provider = new GameCraftOAuthProvider(options, tokenStore, discoveryStore, async (url) => {
|
const provider = new GameCraftOAuthProvider(options, tokenStore, discoveryStore, async (url) => {
|
||||||
authorizationUrl = url;
|
authorizationUrl = url;
|
||||||
@@ -610,7 +611,7 @@ ${url.toString()}`);
|
|||||||
});
|
});
|
||||||
const callback = new OAuthCallbackServer(options.callbackPort, (state) => provider.validateState(state));
|
const callback = new OAuthCallbackServer(options.callbackPort, (state) => provider.validateState(state));
|
||||||
await callback.listen();
|
await callback.listen();
|
||||||
const firstClient = new Client({ name: "crafttable-mcp-cli", version: "0.1.0" });
|
const firstClient = new Client({ name: "crafttable-mcp-cli", version: "0.1.1" });
|
||||||
const firstTransport = new StreamableHTTPClientTransport(options.url, { authProvider: provider });
|
const firstTransport = new StreamableHTTPClientTransport(options.url, { authProvider: provider });
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
@@ -646,7 +647,7 @@ async function logoutRemote(options, tokenStore, discoveryStore, localOnly, fetc
|
|||||||
return { hadCredential: Boolean(tokens), revoked: Boolean(tokens && !localOnly) };
|
return { hadCredential: Boolean(tokens), revoked: Boolean(tokens && !localOnly) };
|
||||||
}
|
}
|
||||||
async function connectWithTransport(options, transport, authentication) {
|
async function connectWithTransport(options, transport, authentication) {
|
||||||
const client = new Client({ name: "crafttable-mcp-cli", version: "0.1.0" });
|
const client = new Client({ name: "crafttable-mcp-cli", version: "0.1.1" });
|
||||||
try {
|
try {
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -662,10 +663,7 @@ async function connectWithTransport(options, transport, authentication) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function discoverRevocationEndpoint(resource, fetchFn) {
|
async function discoverRevocationEndpoint(resource, fetchFn) {
|
||||||
const metadataUrl = new URL(`/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`, resource.origin);
|
const protectedMetadata = await requireOAuthProtectedResourceMetadata(resource, fetchFn);
|
||||||
const protectedResponse = await fetchFn(metadataUrl, { headers: { accept: "application/json" } });
|
|
||||||
if (!protectedResponse.ok) throw new Error(`OAuth protected-resource discovery returned HTTP ${protectedResponse.status}`);
|
|
||||||
const protectedMetadata = await protectedResponse.json();
|
|
||||||
const issuer = Array.isArray(protectedMetadata.authorization_servers) ? protectedMetadata.authorization_servers.find((value) => typeof value === "string") : void 0;
|
const issuer = Array.isArray(protectedMetadata.authorization_servers) ? protectedMetadata.authorization_servers.find((value) => typeof value === "string") : void 0;
|
||||||
if (!issuer) throw new Error("OAuth protected-resource metadata has no authorization server");
|
if (!issuer) throw new Error("OAuth protected-resource metadata has no authorization server");
|
||||||
const issuerUrl = new URL(issuer);
|
const issuerUrl = new URL(issuer);
|
||||||
@@ -684,6 +682,27 @@ async function discoverRevocationEndpoint(resource, fetchFn) {
|
|||||||
}
|
}
|
||||||
throw new Error("OAuth authorization server does not advertise a revocation endpoint");
|
throw new Error("OAuth authorization server does not advertise a revocation endpoint");
|
||||||
}
|
}
|
||||||
|
async function requireOAuthProtectedResourceMetadata(resource, fetchFn = fetch) {
|
||||||
|
const metadataUrl = new URL(`/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`, resource.origin);
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetchFn(metadataUrl, { headers: { accept: "application/json" } });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Could not reach OAuth protected-resource metadata at ${metadataUrl}: ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OAuth is not enabled for ${resource}; protected-resource metadata at ${metadataUrl} returned HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
throw new Error(`OAuth is not enabled for ${resource}; protected-resource metadata at ${metadataUrl} returned ${contentType || "an unknown content type"} instead of JSON`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
throw new Error(`OAuth protected-resource metadata at ${metadataUrl} is not valid JSON`);
|
||||||
|
}
|
||||||
|
}
|
||||||
async function revokeTokens(endpoint, clientId, tokens, fetchFn) {
|
async function revokeTokens(endpoint, clientId, tokens, fetchFn) {
|
||||||
const candidates = [tokens.refresh_token ? { token: tokens.refresh_token, hint: "refresh_token" } : { token: tokens.access_token, hint: "access_token" }];
|
const candidates = [tokens.refresh_token ? { token: tokens.refresh_token, hint: "refresh_token" } : { token: tokens.access_token, hint: "access_token" }];
|
||||||
for (const value of candidates) {
|
for (const value of candidates) {
|
||||||
@@ -718,7 +737,7 @@ async function serveBridge(options, tokenStore, discoveryStore, serviceToken = p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function createProxyServer(upstream) {
|
function createProxyServer(upstream) {
|
||||||
const server = new Server({ name: "crafttable-mcp-stdio-bridge", version: "0.1.0" }, {
|
const server = new Server({ name: "crafttable-mcp-stdio-bridge", version: "0.1.1" }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
resources: {}
|
resources: {}
|
||||||
@@ -786,7 +805,7 @@ function keyringError(error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// src/cli.ts
|
// src/cli.ts
|
||||||
var program = new Command().name("crafttable-mcp").description("OAuth client and stdio bridge for CraftTable MCP").version("0.1.0");
|
var program = new Command().name("crafttable-mcp").description("OAuth client and stdio bridge for CraftTable MCP").version("0.1.1");
|
||||||
withConnection(program.command("login").description("Log in through the system browser")).option("--no-browser", "print the authorization URL instead of opening it").option("--timeout <milliseconds>", "OAuth callback timeout", "600000").action(async (flags) => {
|
withConnection(program.command("login").description("Log in through the system browser")).option("--no-browser", "print the authorization URL instead of opening it").option("--timeout <milliseconds>", "OAuth callback timeout", "600000").action(async (flags) => {
|
||||||
const options = connectionOptions(flags);
|
const options = connectionOptions(flags);
|
||||||
const timeoutMs = positiveInteger(flags.timeout, "OAuth callback timeout");
|
const timeoutMs = positiveInteger(flags.timeout, "OAuth callback timeout");
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@game-crafttable/mcp-client",
|
"name": "@game-crafttable/mcp-client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@game-crafttable/mcp-client",
|
"name": "@game-crafttable/mcp-client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "latest",
|
"@modelcontextprotocol/sdk": "latest",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@game-crafttable/mcp-client",
|
"name": "@game-crafttable/mcp-client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "OAuth CLI and stdio bridge for the CraftTable MCP server",
|
"description": "OAuth CLI and stdio bridge for the CraftTable MCP server",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ export function createProxyServer(upstream: {
|
|||||||
listResourceTemplates: (params?: { cursor?: string }) => Promise<unknown>;
|
listResourceTemplates: (params?: { cursor?: string }) => Promise<unknown>;
|
||||||
readResource: (params: { uri: string }) => Promise<unknown>;
|
readResource: (params: { uri: string }) => Promise<unknown>;
|
||||||
}): Server {
|
}): Server {
|
||||||
const server = new Server({ name: "crafttable-mcp-stdio-bridge", version: "0.1.0" }, {
|
const server = new Server({ name: "crafttable-mcp-stdio-bridge", version: "0.1.1" }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
resources: {},
|
resources: {},
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ type ConnectionFlags = { url?: string; clientId?: string; callbackPort?: string
|
|||||||
const program = new Command()
|
const program = new Command()
|
||||||
.name("crafttable-mcp")
|
.name("crafttable-mcp")
|
||||||
.description("OAuth client and stdio bridge for CraftTable MCP")
|
.description("OAuth client and stdio bridge for CraftTable MCP")
|
||||||
.version("0.1.0");
|
.version("0.1.1");
|
||||||
|
|
||||||
withConnection(program.command("login").description("Log in through the system browser"))
|
withConnection(program.command("login").description("Log in through the system browser"))
|
||||||
.option("--no-browser", "print the authorization URL instead of opening it")
|
.option("--no-browser", "print the authorization URL instead of opening it")
|
||||||
|
|||||||
+29
-6
@@ -44,6 +44,7 @@ export async function loginRemote(
|
|||||||
input: { browser: boolean; timeoutMs?: number; writeLine?: (value: string) => void } = { browser: true },
|
input: { browser: boolean; timeoutMs?: number; writeLine?: (value: string) => void } = { browser: true },
|
||||||
): Promise<{ alreadyAuthenticated: boolean; toolCount: number }> {
|
): Promise<{ alreadyAuthenticated: boolean; toolCount: number }> {
|
||||||
const writeLine = input.writeLine ?? ((value) => process.stderr.write(`${value}\n`));
|
const writeLine = input.writeLine ?? ((value) => process.stderr.write(`${value}\n`));
|
||||||
|
await requireOAuthProtectedResourceMetadata(options.url);
|
||||||
let authorizationUrl: URL | undefined;
|
let authorizationUrl: URL | undefined;
|
||||||
const provider = new GameCraftOAuthProvider(options, tokenStore, discoveryStore, async (url) => {
|
const provider = new GameCraftOAuthProvider(options, tokenStore, discoveryStore, async (url) => {
|
||||||
authorizationUrl = url;
|
authorizationUrl = url;
|
||||||
@@ -60,7 +61,7 @@ export async function loginRemote(
|
|||||||
});
|
});
|
||||||
const callback = new OAuthCallbackServer(options.callbackPort, (state) => provider.validateState(state));
|
const callback = new OAuthCallbackServer(options.callbackPort, (state) => provider.validateState(state));
|
||||||
await callback.listen();
|
await callback.listen();
|
||||||
const firstClient = new Client({ name: "crafttable-mcp-cli", version: "0.1.0" });
|
const firstClient = new Client({ name: "crafttable-mcp-cli", version: "0.1.1" });
|
||||||
const firstTransport = new StreamableHTTPClientTransport(options.url, { authProvider: provider });
|
const firstTransport = new StreamableHTTPClientTransport(options.url, { authProvider: provider });
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
@@ -109,7 +110,7 @@ async function connectWithTransport(
|
|||||||
transport: StreamableHTTPClientTransport,
|
transport: StreamableHTTPClientTransport,
|
||||||
authentication: RemoteConnection["authentication"],
|
authentication: RemoteConnection["authentication"],
|
||||||
): Promise<RemoteConnection> {
|
): Promise<RemoteConnection> {
|
||||||
const client = new Client({ name: "crafttable-mcp-cli", version: "0.1.0" });
|
const client = new Client({ name: "crafttable-mcp-cli", version: "0.1.1" });
|
||||||
try {
|
try {
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -126,10 +127,7 @@ async function connectWithTransport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function discoverRevocationEndpoint(resource: URL, fetchFn: typeof fetch): Promise<URL> {
|
async function discoverRevocationEndpoint(resource: URL, fetchFn: typeof fetch): Promise<URL> {
|
||||||
const metadataUrl = new URL(`/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`, resource.origin);
|
const protectedMetadata = await requireOAuthProtectedResourceMetadata(resource, fetchFn);
|
||||||
const protectedResponse = await fetchFn(metadataUrl, { headers: { accept: "application/json" } });
|
|
||||||
if (!protectedResponse.ok) throw new Error(`OAuth protected-resource discovery returned HTTP ${protectedResponse.status}`);
|
|
||||||
const protectedMetadata = await protectedResponse.json() as { authorization_servers?: unknown };
|
|
||||||
const issuer = Array.isArray(protectedMetadata.authorization_servers)
|
const issuer = Array.isArray(protectedMetadata.authorization_servers)
|
||||||
? protectedMetadata.authorization_servers.find((value): value is string => typeof value === "string")
|
? protectedMetadata.authorization_servers.find((value): value is string => typeof value === "string")
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -152,6 +150,31 @@ async function discoverRevocationEndpoint(resource: URL, fetchFn: typeof fetch):
|
|||||||
throw new Error("OAuth authorization server does not advertise a revocation endpoint");
|
throw new Error("OAuth authorization server does not advertise a revocation endpoint");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requireOAuthProtectedResourceMetadata(
|
||||||
|
resource: URL,
|
||||||
|
fetchFn: typeof fetch = fetch,
|
||||||
|
): Promise<{ authorization_servers?: unknown }> {
|
||||||
|
const metadataUrl = new URL(`/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`, resource.origin);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetchFn(metadataUrl, { headers: { accept: "application/json" } });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Could not reach OAuth protected-resource metadata at ${metadataUrl}: ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OAuth is not enabled for ${resource}; protected-resource metadata at ${metadataUrl} returned HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
throw new Error(`OAuth is not enabled for ${resource}; protected-resource metadata at ${metadataUrl} returned ${contentType || "an unknown content type"} instead of JSON`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await response.json() as { authorization_servers?: unknown };
|
||||||
|
} catch {
|
||||||
|
throw new Error(`OAuth protected-resource metadata at ${metadataUrl} is not valid JSON`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function revokeTokens(endpoint: URL, clientId: string, tokens: OAuthTokens, fetchFn: typeof fetch): Promise<void> {
|
async function revokeTokens(endpoint: URL, clientId: string, tokens: OAuthTokens, fetchFn: typeof fetch): Promise<void> {
|
||||||
const candidates = [tokens.refresh_token
|
const candidates = [tokens.refresh_token
|
||||||
? { token: tokens.refresh_token, hint: "refresh_token" }
|
? { token: tokens.refresh_token, hint: "refresh_token" }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { OAuthCallbackServer } from "../src/callbackServer.js";
|
|||||||
import { credentialAccount, resolveClientOptions, validateMcpUrl } from "../src/config.js";
|
import { credentialAccount, resolveClientOptions, validateMcpUrl } from "../src/config.js";
|
||||||
import type { TokenStore } from "../src/credentials.js";
|
import type { TokenStore } from "../src/credentials.js";
|
||||||
import { GameCraftOAuthProvider } from "../src/oauthProvider.js";
|
import { GameCraftOAuthProvider } from "../src/oauthProvider.js";
|
||||||
import { logoutRemote } from "../src/remote.js";
|
import { logoutRemote, requireOAuthProtectedResourceMetadata } from "../src/remote.js";
|
||||||
|
|
||||||
class MemoryTokens implements TokenStore {
|
class MemoryTokens implements TokenStore {
|
||||||
readonly values = new Map<string, OAuthTokens>();
|
readonly values = new Map<string, OAuthTokens>();
|
||||||
@@ -118,6 +118,21 @@ test("failed remote revocation retains local credentials", async () => {
|
|||||||
assert.equal(tokens.values.has(account), true);
|
assert.equal(tokens.values.has(account), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("OAuth metadata validation reports HTML and missing production routes without JSON parser noise", async () => {
|
||||||
|
const resource = new URL("https://example.test/mcp");
|
||||||
|
await assert.rejects(
|
||||||
|
requireOAuthProtectedResourceMetadata(resource, async () => new Response("<!doctype html>", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/html" },
|
||||||
|
}) as never),
|
||||||
|
/returned text\/html instead of JSON/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
requireOAuthProtectedResourceMetadata(resource, async () => new Response("not found", { status: 404 }) as never),
|
||||||
|
/OAuth is not enabled.*HTTP 404/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
async function availablePort(): Promise<number> {
|
async function availablePort(): Promise<number> {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
|||||||
Reference in New Issue
Block a user