fix: report OAuth metadata deployment errors

This commit is contained in:
cneicy committed 2026-08-20 17:11:45 +08:00
1 parent e56ff6b1e1
commit ac4480bb09
9 files changed
+81 -22

No files matched your search

+2
View File
@@ -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` 环境变量。
远程地址必须使用 HTTPS。只有 `localhost`、`127.0.0.1` 和 `::1` 开发地址允许使用明文 HTTP。
如果 OAuth metadata 路径返回 HTML、404 或非 JSON 响应,CLI 会明确指出服务端 OAuth 尚未启用或反向代理配置错误,不再直接输出 JSON parser 异常。
Vendored
+27 -8
View File
@@ -592,6 +592,7 @@ async function connectRemote(options, tokenStore, discoveryStore, serviceToken =
async function loginRemote(options, tokenStore, discoveryStore, input = { browser: true }) {
const writeLine = input.writeLine ?? ((value) => process.stderr.write(`${value}
`));
await requireOAuthProtectedResourceMetadata(options.url);
let authorizationUrl;
const provider = new GameCraftOAuthProvider(options, tokenStore, discoveryStore, async (url) => {
authorizationUrl = url;
@@ -610,7 +611,7 @@ ${url.toString()}`);
});
const callback = new OAuthCallbackServer(options.callbackPort, (state) => provider.validateState(state));
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 });
try {
try {
@@ -646,7 +647,7 @@ async function logoutRemote(options, tokenStore, discoveryStore, localOnly, fetc
return { hadCredential: Boolean(tokens), revoked: Boolean(tokens && !localOnly) };
}
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 {
await client.connect(transport);
} catch (error) {
@@ -662,10 +663,7 @@ async function connectWithTransport(options, transport, authentication) {
};
}
async function discoverRevocationEndpoint(resource, fetchFn) {
const metadataUrl = new URL(`/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`, resource.origin);
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 protectedMetadata = await requireOAuthProtectedResourceMetadata(resource, fetchFn);
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");
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");
}
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) {
const candidates = [tokens.refresh_token ? { token: tokens.refresh_token, hint: "refresh_token" } : { token: tokens.access_token, hint: "access_token" }];
for (const value of candidates) {
@@ -718,7 +737,7 @@ async function serveBridge(options, tokenStore, discoveryStore, serviceToken = p
}
}
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: {
tools: {},
resources: {}
@@ -786,7 +805,7 @@ function keyringError(error) {
}
// 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) => {
const options = connectionOptions(flags);
const timeoutMs = positiveInteger(flags.timeout, "OAuth callback timeout");
+2 -2
View File
@@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../src/cli.ts", "../src/agents.ts", "../src/config.ts", "../src/bridge.ts", "../src/remote.ts", "../src/callbackServer.ts", "../src/oauthProvider.ts", "../src/credentials.ts"],
"sourcesContent": ["#!/usr/bin/env node\n\nimport { fileURLToPath } from \"node:url\";\nimport { Command } from \"commander\";\nimport { configureAgents, type AgentTarget, unconfigureAgents } from \"./agents.js\";\nimport { serveBridge } from \"./bridge.js\";\nimport { type ClientOptions, credentialAccount, DiscoveryStore, resolveClientOptions } from \"./config.js\";\nimport { KeyringTokenStore } from \"./credentials.js\";\nimport { connectRemote, loginRemote, logoutRemote } from \"./remote.js\";\n\ntype ConnectionFlags = { url?: string; clientId?: string; callbackPort?: string };\n\nconst program = new Command()\n .name(\"crafttable-mcp\")\n .description(\"OAuth client and stdio bridge for CraftTable MCP\")\n .version(\"0.1.0\");\n\nwithConnection(program.command(\"login\").description(\"Log in through the system browser\"))\n .option(\"--no-browser\", \"print the authorization URL instead of opening it\")\n .option(\"--timeout <milliseconds>\", \"OAuth callback timeout\", \"600000\")\n .action(async (flags: ConnectionFlags & { browser: boolean; timeout: string }) => {\n const options = connectionOptions(flags);\n const timeoutMs = positiveInteger(flags.timeout, \"OAuth callback timeout\");\n const result = await loginRemote(options, new KeyringTokenStore(), new DiscoveryStore(), { browser: flags.browser, timeoutMs });\n printJson({ loggedIn: true, alreadyAuthenticated: result.alreadyAuthenticated, server: options.url.toString(), tools: result.toolCount });\n });\n\nwithConnection(program.command(\"status\").description(\"Check saved login state and remote MCP connectivity\"))\n .action(async (flags: ConnectionFlags) => {\n const options = connectionOptions(flags);\n const tokens = await new KeyringTokenStore().get(credentialAccount(options));\n const connection = await connectRemote(options, new KeyringTokenStore(), new DiscoveryStore());\n try {\n const tools = await connection.client.listTools();\n printJson({\n loggedIn: Boolean(tokens),\n authentication: connection.authentication,\n connected: true,\n server: options.url.toString(),\n tools: tools.tools.length,\n });\n } finally {\n await connection.close();\n }\n });\n\nwithConnection(program.command(\"logout\").description(\"Revoke OAuth tokens and remove the local credential\"))\n .option(\"--local-only\", \"remove local credentials without contacting the authorization server\")\n .action(async (flags: ConnectionFlags & { localOnly?: boolean }) => {\n const options = connectionOptions(flags);\n const result = await logoutRemote(options, new KeyringTokenStore(), new DiscoveryStore(), Boolean(flags.localOnly));\n printJson({ loggedIn: false, credentialRemoved: result.hadCredential, revoked: result.revoked, localOnly: Boolean(flags.localOnly) });\n });\n\nwithConnection(program.command(\"tools\").description(\"List remote MCP tools\"))\n .action(async (flags: ConnectionFlags) => withClient(connectionOptions(flags), async (client) => printJson(await client.listTools())));\n\nwithConnection(program.command(\"resources\").description(\"List remote MCP resources and resource templates\"))\n .action(async (flags: ConnectionFlags) => withClient(connectionOptions(flags), async (client) => printJson({\n resources: (await client.listResources()).resources,\n resourceTemplates: (await client.listResourceTemplates()).resourceTemplates,\n })));\n\nwithConnection(program.command(\"read <uri>\").description(\"Read an MCP resource\"))\n .action(async (uri: string, flags: ConnectionFlags) => withClient(connectionOptions(flags), async (client) => printJson(await client.readResource({ uri }))));\n\nwithConnection(program.command(\"call <tool> [json]\").description(\"Call an MCP tool\"))\n .action(async (tool: string, json: string | undefined, flags: ConnectionFlags) => withClient(connectionOptions(flags), async (client) => {\n printJson(await client.callTool({ name: tool, arguments: parseObject(json ?? \"{}\") }));\n }));\n\nwithConnection(program.command(\"serve\").description(\"Run the local stdio bridge\"))\n .action(async (flags: ConnectionFlags) => {\n await serveBridge(connectionOptions(flags), new KeyringTokenStore(), new DiscoveryStore());\n });\n\nfor (const operation of [\"configure\", \"unconfigure\"] as const) {\n withConnection(program.command(`${operation} <agent>`).description(`${operation === \"configure\" ? \"Add\" : \"Remove\"} the stdio bridge and CraftTable Skill in Codex, Claude Code, or OpenCode`))\n .option(\"--dry-run\", \"show the planned changes without writing\")\n .option(\"--force\", \"replace a conflicting entry without prompting\")\n .action(async (agent: AgentTarget, flags: ConnectionFlags & { dryRun?: boolean; force?: boolean }) => {\n const cliEntry = fileURLToPath(import.meta.url);\n if (!cliEntry.endsWith(\".js\")) throw new Error(\"Agent configuration requires the built CLI; run `npm --Line truncated
"mappings": "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 truncated
"sourcesContent": ["#!/usr/bin/env node\n\nimport { fileURLToPath } from \"node:url\";\nimport { Command } from \"commander\";\nimport { configureAgents, type AgentTarget, unconfigureAgents } from \"./agents.js\";\nimport { serveBridge } from \"./bridge.js\";\nimport { type ClientOptions, credentialAccount, DiscoveryStore, resolveClientOptions } from \"./config.js\";\nimport { KeyringTokenStore } from \"./credentials.js\";\nimport { connectRemote, loginRemote, logoutRemote } from \"./remote.js\";\n\ntype ConnectionFlags = { url?: string; clientId?: string; callbackPort?: string };\n\nconst program = new Command()\n .name(\"crafttable-mcp\")\n .description(\"OAuth client and stdio bridge for CraftTable MCP\")\n .version(\"0.1.1\");\n\nwithConnection(program.command(\"login\").description(\"Log in through the system browser\"))\n .option(\"--no-browser\", \"print the authorization URL instead of opening it\")\n .option(\"--timeout <milliseconds>\", \"OAuth callback timeout\", \"600000\")\n .action(async (flags: ConnectionFlags & { browser: boolean; timeout: string }) => {\n const options = connectionOptions(flags);\n const timeoutMs = positiveInteger(flags.timeout, \"OAuth callback timeout\");\n const result = await loginRemote(options, new KeyringTokenStore(), new DiscoveryStore(), { browser: flags.browser, timeoutMs });\n printJson({ loggedIn: true, alreadyAuthenticated: result.alreadyAuthenticated, server: options.url.toString(), tools: result.toolCount });\n });\n\nwithConnection(program.command(\"status\").description(\"Check saved login state and remote MCP connectivity\"))\n .action(async (flags: ConnectionFlags) => {\n const options = connectionOptions(flags);\n const tokens = await new KeyringTokenStore().get(credentialAccount(options));\n const connection = await connectRemote(options, new KeyringTokenStore(), new DiscoveryStore());\n try {\n const tools = await connection.client.listTools();\n printJson({\n loggedIn: Boolean(tokens),\n authentication: connection.authentication,\n connected: true,\n server: options.url.toString(),\n tools: tools.tools.length,\n });\n } finally {\n await connection.close();\n }\n });\n\nwithConnection(program.command(\"logout\").description(\"Revoke OAuth tokens and remove the local credential\"))\n .option(\"--local-only\", \"remove local credentials without contacting the authorization server\")\n .action(async (flags: ConnectionFlags & { localOnly?: boolean }) => {\n const options = connectionOptions(flags);\n const result = await logoutRemote(options, new KeyringTokenStore(), new DiscoveryStore(), Boolean(flags.localOnly));\n printJson({ loggedIn: false, credentialRemoved: result.hadCredential, revoked: result.revoked, localOnly: Boolean(flags.localOnly) });\n });\n\nwithConnection(program.command(\"tools\").description(\"List remote MCP tools\"))\n .action(async (flags: ConnectionFlags) => withClient(connectionOptions(flags), async (client) => printJson(await client.listTools())));\n\nwithConnection(program.command(\"resources\").description(\"List remote MCP resources and resource templates\"))\n .action(async (flags: ConnectionFlags) => withClient(connectionOptions(flags), async (client) => printJson({\n resources: (await client.listResources()).resources,\n resourceTemplates: (await client.listResourceTemplates()).resourceTemplates,\n })));\n\nwithConnection(program.command(\"read <uri>\").description(\"Read an MCP resource\"))\n .action(async (uri: string, flags: ConnectionFlags) => withClient(connectionOptions(flags), async (client) => printJson(await client.readResource({ uri }))));\n\nwithConnection(program.command(\"call <tool> [json]\").description(\"Call an MCP tool\"))\n .action(async (tool: string, json: string | undefined, flags: ConnectionFlags) => withClient(connectionOptions(flags), async (client) => {\n printJson(await client.callTool({ name: tool, arguments: parseObject(json ?? \"{}\") }));\n }));\n\nwithConnection(program.command(\"serve\").description(\"Run the local stdio bridge\"))\n .action(async (flags: ConnectionFlags) => {\n await serveBridge(connectionOptions(flags), new KeyringTokenStore(), new DiscoveryStore());\n });\n\nfor (const operation of [\"configure\", \"unconfigure\"] as const) {\n withConnection(program.command(`${operation} <agent>`).description(`${operation === \"configure\" ? \"Add\" : \"Remove\"} the stdio bridge and CraftTable Skill in Codex, Claude Code, or OpenCode`))\n .option(\"--dry-run\", \"show the planned changes without writing\")\n .option(\"--force\", \"replace a conflicting entry without prompting\")\n .action(async (agent: AgentTarget, flags: ConnectionFlags & { dryRun?: boolean; force?: boolean }) => {\n const cliEntry = fileURLToPath(import.meta.url);\n if (!cliEntry.endsWith(\".js\")) throw new Error(\"Agent configuration requires the built CLI; run `npm --Line truncated
"mappings": "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 truncated
"names": ["mkdir", "readFile", "rename", "writeFile", "os", "path", "path", "os", "readFile", "mkdir", "rename", "writeFile"]
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@game-crafttable/mcp-client",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@game-crafttable/mcp-client",
"version": "0.1.0",
"version": "0.1.1",
"license": "UNLICENSED",
"dependencies": {
"@modelcontextprotocol/sdk": "latest",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@game-crafttable/mcp-client",
"version": "0.1.0",
"version": "0.1.1",
"description": "OAuth CLI and stdio bridge for the CraftTable MCP server",
"repository": {
"type": "git",
+1 -1
View File
@@ -45,7 +45,7 @@ export function createProxyServer(upstream: {
listResourceTemplates: (params?: { cursor?: string }) => Promise<unknown>;
readResource: (params: { uri: string }) => Promise<unknown>;
}): 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: {
tools: {},
resources: {},
+1 -1
View File
@@ -13,7 +13,7 @@ type ConnectionFlags = { url?: string; clientId?: string; callbackPort?: string
const program = new Command()
.name("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"))
.option("--no-browser", "print the authorization URL instead of opening it")
+29 -6
View File
@@ -44,6 +44,7 @@ export async function loginRemote(
input: { browser: boolean; timeoutMs?: number; writeLine?: (value: string) => void } = { browser: true },
): Promise<{ alreadyAuthenticated: boolean; toolCount: number }> {
const writeLine = input.writeLine ?? ((value) => process.stderr.write(`${value}\n`));
await requireOAuthProtectedResourceMetadata(options.url);
let authorizationUrl: URL | undefined;
const provider = new GameCraftOAuthProvider(options, tokenStore, discoveryStore, async (url) => {
authorizationUrl = url;
@@ -60,7 +61,7 @@ export async function loginRemote(
});
const callback = new OAuthCallbackServer(options.callbackPort, (state) => provider.validateState(state));
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 });
try {
try {
@@ -109,7 +110,7 @@ async function connectWithTransport(
transport: StreamableHTTPClientTransport,
authentication: RemoteConnection["authentication"],
): 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 {
await client.connect(transport);
} catch (error) {
@@ -126,10 +127,7 @@ async function connectWithTransport(
}
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 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 protectedMetadata = await requireOAuthProtectedResourceMetadata(resource, fetchFn);
const issuer = Array.isArray(protectedMetadata.authorization_servers)
? protectedMetadata.authorization_servers.find((value): value is string => typeof value === "string")
: 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");
}
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> {
const candidates = [tokens.refresh_token
? { token: tokens.refresh_token, hint: "refresh_token" }
+16 -1
View File
@@ -6,7 +6,7 @@ import { OAuthCallbackServer } from "../src/callbackServer.js";
import { credentialAccount, resolveClientOptions, validateMcpUrl } from "../src/config.js";
import type { TokenStore } from "../src/credentials.js";
import { GameCraftOAuthProvider } from "../src/oauthProvider.js";
import { logoutRemote } from "../src/remote.js";
import { logoutRemote, requireOAuthProtectedResourceMetadata } from "../src/remote.js";
class MemoryTokens implements TokenStore {
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);
});
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> {
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));