From f54fa8b93d7e80ee2a6367426de6ba610af2ed36 Mon Sep 17 00:00:00 2001 From: Ali Farhadi Date: Fri, 23 Oct 2026 14:08:21 +0330 Subject: [PATCH 1/4] feat: Bugfender Claude Plugin - Updated the hosted OAuth resource to use the exact MCP endpoint URL --- README.md | 7 +++---- src/__tests__/http-server.test.ts | 18 +++++++++++------- src/http-server.ts | 25 +++++++++++++++++++++++-- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c18a892..79147fa 100644 --- a/README.md +++ b/README.md @@ -212,17 +212,16 @@ exposes Prometheus metrics on `GET /metrics` for cluster-internal scraping. Hosted mode publishes OAuth protected-resource metadata at `GET /.well-known/oauth-protected-resource`. Authentication challenges point clients to that document. Production defaults to resource -`https://mcp.bugfender.com`, issuer `https://app.bugfender.com`, and API +`https://mcp.bugfender.com/mcp`, issuer `https://app.bugfender.com`, and API `https://app.bugfender.com/api`; staging deployments must override `BUGFENDER_MCP_RESOURCE_URL` and -`BUGFENDER_OAUTH_ISSUER` and `BUGFENDER_API_URL` with their staging origins. +`BUGFENDER_OAUTH_ISSUER` and `BUGFENDER_API_URL` with their staging values. Documentation, privacy, and terms links can be overridden with `BUGFENDER_MCP_DOCUMENTATION_URL`, `BUGFENDER_MCP_PRIVACY_POLICY_URL`, and `BUGFENDER_MCP_TERMS_URL`. ### Hosted authentication troubleshooting -- Use the exact endpoint `https://mcp.bugfender.com/mcp`; the OAuth resource is - the origin `https://mcp.bugfender.com` without `/mcp`. +- Use the exact endpoint and OAuth resource `https://mcp.bugfender.com/mcp`. - Do not configure `BUGFENDER_API_TOKEN` or paste a bearer token into a remote-hosted connection. - Disconnect and reconnect to recover from expired or revoked grants or to request different scopes. diff --git a/src/__tests__/http-server.test.ts b/src/__tests__/http-server.test.ts index 1fe514e..3bd4ab0 100644 --- a/src/__tests__/http-server.test.ts +++ b/src/__tests__/http-server.test.ts @@ -21,7 +21,7 @@ function options(overrides: Partial = {}): HostedHttpOptions maxConcurrency: 10, requestTimeoutMs: 2_000, shutdownTimeoutMs: 500, - resourceUrl: "https://mcp.test", + resourceUrl: "https://mcp.test/mcp", authorizationServerUrl: "https://dashboard.test", resourceDocumentationUrl: "https://docs.test/mcp", resourcePolicyUrl: "https://www.test/privacy", @@ -88,7 +88,7 @@ describe("hosted HTTP server", () => { expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("no-store"); expect(await response.json()).toEqual({ - resource: "https://mcp.test", + resource: "https://mcp.test/mcp", resource_name: "Bugfender MCP", authorization_servers: ["https://dashboard.test"], scopes_supported: ["mcp:read", "mcp:issues:write"], @@ -455,8 +455,8 @@ describe("hosted HTTP server", () => { describe("hosted configuration", () => { it("creates isolated non-persisting runtime configuration", () => { - const first = createHostedRuntimeConfig("first-token", "https://api.test/", "https://mcp.test"); - const second = createHostedRuntimeConfig("second-token", "https://api.test/", "https://mcp.test"); + const first = createHostedRuntimeConfig("first-token", "https://api.test/", "https://mcp.test/mcp"); + const second = createHostedRuntimeConfig("second-token", "https://api.test/", "https://mcp.test/mcp"); expect(first).not.toBe(second); expect(first.apiToken).toBe("first-token"); @@ -471,7 +471,7 @@ describe("hosted configuration", () => { it("loads limits from environment and rejects invalid values", () => { expect(loadHostedHttpOptions({}).port).toBe(3002); expect(loadHostedHttpOptions({}).apiUrl).toBe("https://app.bugfender.com/api"); - expect(loadHostedHttpOptions({}).resourceUrl).toBe("https://mcp.bugfender.com"); + expect(loadHostedHttpOptions({}).resourceUrl).toBe("https://mcp.bugfender.com/mcp"); expect(loadHostedHttpOptions({}).authorizationServerUrl).toBe("https://app.bugfender.com"); const loaded = loadHostedHttpOptions({ BUGFENDER_MCP_PORT: "4000", @@ -482,8 +482,12 @@ describe("hosted configuration", () => { expect(() => loadHostedHttpOptions({ BUGFENDER_MCP_PORT: "nope" })).toThrow( "BUGFENDER_MCP_PORT must be a positive integer", ); - expect(loadHostedHttpOptions({ BUGFENDER_MCP_RESOURCE_URL: "https://mcp.test/" }).resourceUrl) - .toBe("https://mcp.test"); + expect(loadHostedHttpOptions({ BUGFENDER_MCP_RESOURCE_URL: "https://mcp.test/mcp" }).resourceUrl) + .toBe("https://mcp.test/mcp"); + expect(() => loadHostedHttpOptions({ BUGFENDER_MCP_RESOURCE_URL: "https://mcp.test" })) + .toThrow("BUGFENDER_MCP_RESOURCE_URL must be an HTTPS endpoint URL"); + expect(() => loadHostedHttpOptions({ BUGFENDER_MCP_RESOURCE_URL: "https://mcp.test/mcp/" })) + .toThrow("BUGFENDER_MCP_RESOURCE_URL must be an HTTPS endpoint URL"); expect(() => loadHostedHttpOptions({ BUGFENDER_OAUTH_ISSUER: "http://dashboard.test" })) .toThrow("BUGFENDER_OAUTH_ISSUER must be an HTTPS origin"); }); diff --git a/src/http-server.ts b/src/http-server.ts index b8ba536..2fd4ee0 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -13,7 +13,7 @@ const DEFAULT_MAX_CONCURRENCY = 100; const DEFAULT_REQUEST_TIMEOUT_MS = 35_000; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000; const DEFAULT_HOSTED_API_URL = "https://app.bugfender.com/api"; -const DEFAULT_RESOURCE_URL = "https://mcp.bugfender.com"; +const DEFAULT_RESOURCE_URL = "https://mcp.bugfender.com/mcp"; const DEFAULT_AUTHORIZATION_SERVER_URL = "https://app.bugfender.com"; const DEFAULT_RESOURCE_DOCUMENTATION_URL = "https://docs.bugfender.com/docs/mcp/overview"; const DEFAULT_RESOURCE_POLICY_URL = "https://bugfender.com/privacy-policy/"; @@ -129,6 +129,27 @@ function httpsOrigin(value: string, name: string): string { return parsed.origin; } +function httpsResourceUrl(value: string, name: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(`${name} must be a valid URL`); + } + if ( + parsed.protocol !== "https:" + || parsed.username !== "" + || parsed.password !== "" + || parsed.search !== "" + || parsed.hash !== "" + || parsed.pathname === "/" + || parsed.pathname.endsWith("/") + ) { + throw new Error(`${name} must be an HTTPS endpoint URL without credentials, a trailing slash, query, or fragment`); + } + return parsed.toString(); +} + export function loadHostedHttpOptions(env: NodeJS.ProcessEnv = process.env): HostedHttpOptions { return { apiUrl: env.BUGFENDER_API_URL || DEFAULT_HOSTED_API_URL, @@ -159,7 +180,7 @@ export function loadHostedHttpOptions(env: NodeJS.ProcessEnv = process.env): Hos DEFAULT_SHUTDOWN_TIMEOUT_MS, "BUGFENDER_MCP_SHUTDOWN_TIMEOUT_MS", ), - resourceUrl: httpsOrigin( + resourceUrl: httpsResourceUrl( env.BUGFENDER_MCP_RESOURCE_URL || DEFAULT_RESOURCE_URL, "BUGFENDER_MCP_RESOURCE_URL", ), From af4a2822cdc86238371c28fed4ad269aa4715e9f Mon Sep 17 00:00:00 2001 From: Ali Farhadi Date: Mon, 26 Oct 2026 14:14:54 +0330 Subject: [PATCH 2/4] feat: Bugfender Claude Plugin - Hardened MCP errors and redacted credentials from tool responses - Added bounded responses, safer pagination, and smaller log and crash-device result limits - Tightened tool schemas and metadata for Anthropic connector compatibility - Expanded tests for truncation, sanitization, error handling, and concurrent user isolation --- src/__tests__/envelope.test.ts | 57 ++++++++++++++++---- src/__tests__/http-server.test.ts | 7 +-- src/__tests__/tool-metadata.test.ts | 1 + src/constants.ts | 3 +- src/envelope.ts | 80 ++++++++++++++++++++++++----- src/http-server.ts | 4 +- src/tool-metadata.ts | 44 ++++++++++++++-- src/tools/apps.ts | 2 +- src/tools/crashes.ts | 7 ++- src/tools/issues.ts | 14 ++--- src/tools/logs.ts | 7 ++- 11 files changed, 180 insertions(+), 46 deletions(-) diff --git a/src/__tests__/envelope.test.ts b/src/__tests__/envelope.test.ts index d116ac3..ae410ee 100644 --- a/src/__tests__/envelope.test.ts +++ b/src/__tests__/envelope.test.ts @@ -35,10 +35,10 @@ describe("toolError", () => { expect(result.error.code).toBe("unauthorized"); }); - it("maps 409 status to email_taken code", () => { + it("maps 409 status to conflict code", () => { const err = new BugfenderApiError("fail", 409, null); const result = toolError(err, true) as ErrorEnvelope; - expect(result.error.code).toBe("email_taken"); + expect(result.error.code).toBe("conflict"); }); it("maps refresh_expired body code to token_expired", () => { @@ -53,38 +53,53 @@ describe("toolError", () => { expect(result.error.code).toBe("api_error"); }); - it("uses body message when present", () => { - const err = new BugfenderApiError("fail", 400, { message: "Bad input" }); + it("does not expose API response details", () => { + const err = new BugfenderApiError("internal route detail", 400, { message: "secret backend detail" }); const result = toolError(err, true) as ErrorEnvelope; - expect(result.error.message).toBe("Bad input"); + expect(result.error.message).toBe("Bugfender rejected the request. Check the tool arguments and try again."); + expect(JSON.stringify(result)).not.toContain("secret"); + expect(JSON.stringify(result)).not.toContain("internal route"); }); - it("handles non-BugfenderApiError Error instances", () => { + it("does not expose unexpected internal error details", () => { const err = new Error("something broke"); const result = toolError(err, true) as ErrorEnvelope; expect(result.error.code).toBe("api_error"); - expect(result.error.message).toBe("something broke"); + expect(result.error.message).toBe("Bugfender MCP could not complete the request."); + }); + + it("retains actionable input validation errors", () => { + const err = new Error("Invalid issue status: unknown"); + const result = toolError(err, true) as ErrorEnvelope; + expect(result.error.message).toBe("Invalid issue status: unknown"); }); it("handles non-Error values", () => { const result = toolError("string error", true) as ErrorEnvelope; expect(result.error.code).toBe("api_error"); - expect(result.error.message).toBe("Unknown Bugfender error"); + expect(result.error.message).toBe("Bugfender MCP could not complete the request."); }); }); describe("serializeEnvelope", () => { it("passes through small success envelopes", () => { const envelope: Envelope = { ok: true, data: { x: 1 } }; - expect(serializeEnvelope(envelope)).toBe(envelope); + expect(serializeEnvelope(envelope)).toEqual(envelope); }); - it("passes through error envelopes regardless of size", () => { + it("replaces oversized error envelopes with a safe bounded error", () => { const envelope: Envelope = { ok: false, error: { code: "api_error", message: "x".repeat(600_000) }, }; - expect(serializeEnvelope(envelope)).toBe(envelope); + expect(serializeEnvelope(envelope)).toEqual({ + ok: false, + error: { + code: "response_too_large", + message: "Bugfender returned an error that was too large to display safely.", + hint: "Retry with narrower filters. If the problem persists, contact Bugfender support.", + }, + }); }); it("truncates large success envelopes", () => { @@ -95,6 +110,25 @@ describe("serializeEnvelope", () => { expect(result.warnings).toEqual( expect.arrayContaining([expect.objectContaining({ code: "truncated" })]), ); + expect(result.warnings?.[0]?.message).toContain("smaller page or narrower filters"); + }); + + it("removes credentials before serializing tool output", () => { + const envelope: Envelope = { + ok: true, + data: { + authorization: "Bearer secret", + nested: { apiKey: "secret", value: "kept" }, + url: "https://example.com/callback?token=secret&item=kept", + }, + }; + expect(serializeEnvelope(envelope)).toEqual({ + ok: true, + data: { + nested: { value: "kept" }, + url: "https://example.com/callback?token=%5BREDACTED%5D&item=kept", + }, + }); }); }); @@ -113,6 +147,7 @@ describe("handleTool", () => { throw new Error("boom"); }); expect(result.structuredContent.ok).toBe(false); + expect(result.isError).toBe(true); }); it("returns OAuth linking metadata for hosted authentication errors", async () => { diff --git a/src/__tests__/http-server.test.ts b/src/__tests__/http-server.test.ts index 3bd4ab0..bd9f955 100644 --- a/src/__tests__/http-server.test.ts +++ b/src/__tests__/http-server.test.ts @@ -181,6 +181,7 @@ describe("hosted HTTP server", () => { for (const tool of tools) { expect(tool.name).toMatch(/^[a-z][a-z0-9_]*$/); + expect(tool.name.length, `${tool.name} exceeds Anthropic's 64-character limit`).toBeLessThanOrEqual(64); expect(tool.title?.trim()).toBeTruthy(); expect(tool.description?.trim()).toBeTruthy(); const outputVariants = [tool.outputSchema, ...(tool.outputSchema?.anyOf ?? [])]; @@ -386,7 +387,7 @@ describe("hosted HTTP server", () => { const headers = new Headers(init?.headers); const authorization = headers.get("Authorization"); await new Promise((resolve) => setTimeout(resolve, authorization === "Bearer first-user-token" ? 20 : 5)); - return new Response(JSON.stringify({ authorization }), { + return new Response(JSON.stringify({ caller: authorization === "Bearer first-user-token" ? "first" : "second" }), { status: 200, headers: { "Content-Type": "application/json" }, }); @@ -419,11 +420,11 @@ describe("hosted HTTP server", () => { expect(first.result?.structuredContent).toEqual({ ok: true, - data: { authorization: "Bearer first-user-token" }, + data: { caller: "first" }, }); expect(second.result?.structuredContent).toEqual({ ok: true, - data: { authorization: "Bearer second-user-token" }, + data: { caller: "second" }, }); }); diff --git a/src/__tests__/tool-metadata.test.ts b/src/__tests__/tool-metadata.test.ts index c0f445f..ea5e58f 100644 --- a/src/__tests__/tool-metadata.test.ts +++ b/src/__tests__/tool-metadata.test.ts @@ -9,6 +9,7 @@ describe("tool response metadata", () => { profile: { name: "Example", access_token: "access-secret", + clientSecret: "client-secret", }, apps: [{ public_id: "abc", refresh_token: "refresh-secret" }], })).toEqual({ diff --git a/src/constants.ts b/src/constants.ts index e7bc4f1..3cd6ff7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url"; export const DEFAULT_API_URL = "https://dashboard.bugfender.com/api"; export const DEFAULT_PAGE_SIZE = 100; export const MAX_PAGE_SIZE = 200; -export const MAX_RESPONSE_BYTES = 512 * 1024; +export const MAX_TOOL_RESULT_BYTES = 96 * 1024; +export const MAX_HTTP_RESPONSE_BYTES = 512 * 1024; export const SERVER_NAME = "bugfender"; diff --git a/src/envelope.ts b/src/envelope.ts index 9855ea4..79d1f02 100644 --- a/src/envelope.ts +++ b/src/envelope.ts @@ -1,9 +1,10 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { MAX_RESPONSE_BYTES } from "./constants.js"; +import { MAX_TOOL_RESULT_BYTES } from "./constants.js"; import { BugfenderApiError } from "./errors.js"; import type { ServerContext } from "./server-context.js"; import type { Envelope, Warning } from "./types.js"; import { MCP_READ_SCOPE, oauthChallenge } from "./oauth.js"; +import { omitCredentials } from "./tool-metadata.js"; type EnvelopeToolResult = CallToolResult & { structuredContent: Envelope; @@ -23,18 +24,48 @@ export function authHint(hasToken: boolean, status?: number): string { return "Check your Bugfender token and app/team permissions."; } +function apiRecoveryHint(status: number, hasToken: boolean): string { + if (status === 401 || status === 403) { + return authHint(hasToken, status); + } + if (status === 400) { + return "Review the documented tool inputs, especially identifiers, filters, and date formats."; + } + if (status === 404) { + return "Verify the app and resource identifiers and confirm the connected account can access them."; + } + if (status === 409) { + return "Read the current resource state, then retry only if the requested change is still applicable."; + } + if (status === 429) { + return "Wait briefly before retrying, and reduce repeated or high-volume requests."; + } + return "Retry later. If the problem persists, contact Bugfender support with the operation and time of failure."; +} + export function toolError(error: unknown, hasToken: boolean): Envelope { if (error instanceof BugfenderApiError) { const body = typeof error.body === "object" && error.body ? (error.body as Record) : null; const errorCode = body && "code" in body ? String(body.code) : undefined; - const message = - body && "message" in body - ? String(body.message) - : error.message; + const message = errorCode === "refresh_expired" + ? "Your Bugfender MCP session expired and must be reconnected." + : error.status === 400 + ? "Bugfender rejected the request. Check the tool arguments and try again." + : error.status === 401 + ? "Bugfender authentication is required." + : error.status === 403 + ? "The connected Bugfender account does not have permission for this operation." + : error.status === 404 + ? "The requested Bugfender resource was not found or is not accessible." + : error.status === 409 + ? "The requested change conflicts with the current Bugfender state." + : error.status === 429 + ? "Bugfender rate limiting prevented the request. Retry after a short delay." + : "Bugfender could not complete the request. Try again later."; const hint = errorCode === "refresh_expired" ? "Your Bugfender MCP session can no longer refresh automatically. Open Bugfender MCP Setup, generate a fresh config, and replace both BUGFENDER_API_TOKEN and BUGFENDER_REFRESH_TOKEN in your IDE." - : authHint(hasToken, error.status); + : apiRecoveryHint(error.status, hasToken); return { ok: false, @@ -45,7 +76,7 @@ export function toolError(error: unknown, hasToken: boolean): Envelope { : error.status === 401 || error.status === 403 ? "unauthorized" : error.status === 409 - ? "email_taken" + ? "conflict" : "api_error", message, hint, @@ -57,23 +88,43 @@ export function toolError(error: unknown, hasToken: boolean): Envelope { ok: false, error: { code: "api_error", - message: error instanceof Error ? error.message : "Unknown Bugfender error", - hint: authHint(hasToken), + message: error instanceof Error && error.message.startsWith("Invalid ") + ? error.message + : "Bugfender MCP could not complete the request.", + hint: "Check the tool inputs and retry. If the problem persists, contact Bugfender support with the operation and time of failure.", }, }; } export function serializeEnvelope(envelope: Envelope): Envelope { - const json = JSON.stringify(envelope); - if (Buffer.byteLength(json, "utf8") <= MAX_RESPONSE_BYTES || !envelope.ok) { - return envelope; + const sanitized = omitCredentials(envelope) as Envelope; + const json = JSON.stringify(sanitized); + if (Buffer.byteLength(json, "utf8") <= MAX_TOOL_RESULT_BYTES) { + return sanitized; + } + + if (!sanitized.ok) { + return { + ok: false, + error: { + code: "response_too_large", + message: "Bugfender returned an error that was too large to display safely.", + hint: "Retry with narrower filters. If the problem persists, contact Bugfender support.", + }, + }; } return { ok: true, data: { truncated: true }, - pagination: envelope.pagination, - warnings: [...(envelope.warnings ?? []), { code: "truncated", message: "Response exceeded 512 KB and was truncated." }], + pagination: sanitized.pagination, + warnings: [ + ...(sanitized.warnings ?? []), + { + code: "truncated", + message: "Response exceeded 96 KB. Request a smaller page or narrower filters and try again.", + }, + ], }; } @@ -117,5 +168,6 @@ export function asToolResult(envelope: Envelope): EnvelopeToolResult { return { content: [{ type: "text" as const, text: JSON.stringify(serialized, null, 2) }], structuredContent: serialized, + isError: !serialized.ok, }; } diff --git a/src/http-server.ts b/src/http-server.ts index 2fd4ee0..14027cd 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { MAX_RESPONSE_BYTES } from "./constants.js"; +import { MAX_HTTP_RESPONSE_BYTES } from "./constants.js"; import { createBugfenderServer } from "./server.js"; import { MCP_READ_SCOPE, oauthChallenge } from "./oauth.js"; import type { RuntimeConfig } from "./types.js"; @@ -162,7 +162,7 @@ export function loadHostedHttpOptions(env: NodeJS.ProcessEnv = process.env): Hos ), maxResponseBytes: positiveInteger( env.BUGFENDER_MCP_MAX_RESPONSE_BYTES, - MAX_RESPONSE_BYTES, + MAX_HTTP_RESPONSE_BYTES, "BUGFENDER_MCP_MAX_RESPONSE_BYTES", ), maxConcurrency: positiveInteger( diff --git a/src/tool-metadata.ts b/src/tool-metadata.ts index 5c413b6..ffe5603 100644 --- a/src/tool-metadata.ts +++ b/src/tool-metadata.ts @@ -27,27 +27,65 @@ export const readOnlyToolAnnotations = { export const readOnlyOAuthMetadata = oauthSecurityMetadata([MCP_READ_SCOPE]); const sensitiveResponseFields = new Set([ + "accesstoken", "access_token", + "apikey", "api_token", + "appkey", + "auth_token", + "authorization", + "bearer_token", + "clientsecret", + "client_secret", + "cookie", "key", "password", + "private_key", + "refreshtoken", "refresh_token", + "set-cookie", "secret", + "session_token", "token", + "x-api-key", ]); +function isSensitiveField(key: string): boolean { + return sensitiveResponseFields.has(key.toLowerCase()); +} + +function redactSensitiveUrl(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return value; + } + let changed = false; + for (const key of [...parsed.searchParams.keys()]) { + if (isSensitiveField(key)) { + parsed.searchParams.set(key, "[REDACTED]"); + changed = true; + } + } + return changed ? parsed.toString() : value; +} + /** Removes credentials from broad account/application responses before they reach an MCP client. */ -export function omitCredentials(value: unknown): unknown { +export function omitCredentials(value: unknown, parentKey = ""): unknown { if (Array.isArray(value)) { - return value.map(omitCredentials); + return value.map((child) => omitCredentials(child, parentKey)); } if (typeof value !== "object" || value === null) { + if (typeof value === "string" && ["url", "uri"].some((suffix) => parentKey.toLowerCase().endsWith(suffix))) { + return redactSensitiveUrl(value); + } return value; } return Object.fromEntries( Object.entries(value).flatMap(([key, child]) => - sensitiveResponseFields.has(key.toLowerCase()) ? [] : [[key, omitCredentials(child)]], + isSensitiveField(key) ? [] : [[key, omitCredentials(child, key)]], ), ); } diff --git a/src/tools/apps.ts b/src/tools/apps.ts index 5a12103..01c97fe 100644 --- a/src/tools/apps.ts +++ b/src/tools/apps.ts @@ -48,7 +48,7 @@ export function registerAppTools(server: McpServer, context: ServerContext): voi "get_sdk_snippet", { title: "Get Bugfender SDK Snippet", - description: "Generates the Bugfender SDK setup snippet for an app and supported platform.", + description: "Generates a client-side Bugfender SDK setup snippet, including the selected app's SDK key, for a supported platform.", inputSchema: { app_id: z.string().describe("Public app ID returned by list_apps (for example, 5X3c4veRGV)."), platform: z.enum(["ios", "android", "web", "flutter"]).describe("Target SDK platform."), diff --git a/src/tools/crashes.ts b/src/tools/crashes.ts index b6f7af9..7ddced7 100644 --- a/src/tools/crashes.ts +++ b/src/tools/crashes.ts @@ -110,16 +110,19 @@ export function registerCrashTools(server: McpServer, context: ServerContext): v inputSchema: { app_id: z.string().describe("Public app ID returned by list_apps."), hash: z.string().describe("Crash group hash returned by get_crashes or list_issues."), + device_page_size: z.number().int().positive().optional().default(25).describe("Maximum affected-device samples to return; defaults to 25 and is capped at 100."), + device_page: z.number().int().positive().optional().default(1).describe("One-based affected-device result page; defaults to 1."), }, outputSchema: toolEnvelopeSchema, annotations: readOnlyToolAnnotations, _meta: readOnlyOAuthMetadata, - }, ({ app_id, hash }) => + }, ({ app_id, hash, device_page_size = 25, device_page = 1 }) => handleTool(context, async () => { const [details, devices] = await Promise.all([ context.client.get(`/app/${app_id}/issues-aggregation/${hash}`), context.client.get(`/app/${app_id}/issues-aggregation/${hash}/devices`, { - page_size: MAX_PAGE_SIZE, + page_size: Math.min(device_page_size, 100), + page: device_page, }), ]); return ok({ details, devices }); diff --git a/src/tools/issues.ts b/src/tools/issues.ts index 8de3042..7bdac97 100644 --- a/src/tools/issues.ts +++ b/src/tools/issues.ts @@ -83,8 +83,8 @@ export function registerIssueTools(server: McpServer, context: ServerContext): v description: "Lists issue, crash, or feedback groups for an app with status, date, content, and version filters.", inputSchema: { app_id: z.string().describe("The public app ID (e.g. 5X3c4veRGV) from list_apps"), - type: z.string().optional().describe("Filter by type: issue, crash, feedback"), - issue_status: z.string().optional().describe("Filter by status: new, open, in_progress, resolved, closed, muted"), + type: z.enum(["issue", "crash", "feedback"]).optional().describe("Filter by issue group type."), + issue_status: z.enum(["new", "open", "in_progress", "resolved", "closed", "muted", "invalid"]).optional().describe("Filter by issue status."), date_range_start: z.string().optional().describe("ISO 8601 datetime string (e.g. 2026-04-28T00:00:00Z)"), date_range_end: z.string().optional().describe("ISO 8601 datetime string (e.g. 2026-04-28T23:59:59Z)"), query: z.string().optional().describe("Filter by issue title text."), @@ -158,7 +158,7 @@ export function registerIssueTools(server: McpServer, context: ServerContext): v inputSchema: { app_id: z.string().describe("Public app ID returned by list_apps."), issue_id: z.string().describe("Issue group hash returned by list_issues or get_issue."), - status: z.string().describe("New status: new, open, in_progress, resolved, closed, muted, or invalid."), + status: z.enum(["new", "open", "in_progress", "resolved", "closed", "muted", "invalid"]).describe("New issue status."), }, outputSchema: toolEnvelopeSchema, annotations: { @@ -236,8 +236,8 @@ export function registerIssueTools(server: McpServer, context: ServerContext): v app_id: z.string().describe("The public app ID (e.g. 5X3c4veRGV) from list_apps"), date_range_start: z.string().describe("ISO 8601 datetime string (e.g. 2026-04-21T00:00:00Z)"), date_range_end: z.string().describe("ISO 8601 datetime string (e.g. 2026-04-28T23:59:59Z)"), - type: z.string().optional().describe("Filter by type: issue, crash, feedback"), - issue_status: z.string().optional().describe("Filter by status: new, open, in_progress, resolved, closed, muted"), + type: z.enum(["issue", "crash", "feedback"]).optional().describe("Filter by issue group type."), + issue_status: z.enum(["new", "open", "in_progress", "resolved", "closed", "muted", "invalid"]).optional().describe("Filter by issue status."), query: z.string().optional().describe("Filter by issue title text."), content: z.string().optional().describe("Filter by issue body/content text."), version: z.number().int().optional().describe("Filter by app version ID."), @@ -268,8 +268,8 @@ export function registerIssueTools(server: McpServer, context: ServerContext): v app_id: z.string().describe("The public app ID (e.g. 5X3c4veRGV) from list_apps"), date_range_start: z.string().describe("ISO 8601 datetime string (e.g. 2026-04-21T00:00:00Z)"), date_range_end: z.string().describe("ISO 8601 datetime string (e.g. 2026-04-28T23:59:59Z)"), - type: z.string().optional().describe("Filter by type: issue, crash, feedback"), - issue_status: z.string().optional().describe("Filter by status: new, open, in_progress, resolved, closed, muted"), + type: z.enum(["issue", "crash", "feedback"]).optional().describe("Filter by issue group type."), + issue_status: z.enum(["new", "open", "in_progress", "resolved", "closed", "muted", "invalid"]).optional().describe("Filter by issue status."), query: z.string().optional().describe("Filter by issue title text."), content: z.string().optional().describe("Filter by issue body/content text."), version: z.number().int().optional().describe("Filter by app version ID."), diff --git a/src/tools/logs.ts b/src/tools/logs.ts index c74947f..d205eb2 100644 --- a/src/tools/logs.ts +++ b/src/tools/logs.ts @@ -6,6 +6,9 @@ import { readOnlyOAuthMetadata, readOnlyToolAnnotations, toolEnvelopeSchema } fr import { normalizeEndDate, normalizeStartDate } from "../utils/date.js"; import { clampPageSize } from "../utils/pagination.js"; +const DEFAULT_LOG_PAGE_SIZE = 25; +const MAX_LOG_PAGE_SIZE = 100; + export const searchLogsSchema = { app_id: z .string() @@ -14,7 +17,7 @@ export const searchLogsSchema = { device_udid: z.string().optional().describe("Filter to a specific device by its UDID from search_devices."), date_range_start: z.string().optional().describe("ISO 8601 datetime string (e.g. 2026-04-28T00:00:00Z). Date-only strings like 2026-04-28 are also accepted."), date_range_end: z.string().optional().describe("ISO 8601 datetime string (e.g. 2026-04-28T23:59:59Z). Date-only strings like 2026-04-28 are also accepted."), - page_size: z.number().int().positive().optional().describe("Number of results per page. Defaults to 100, max 200. Use smaller values (e.g. 25) to avoid truncation."), + page_size: z.number().int().positive().optional().describe("Number of log entries per page. Defaults to 25 and is capped at 100."), cursor: z.string().optional().describe("Pagination cursor from pagination.next in a previous response."), level: z .number() @@ -53,7 +56,7 @@ export function registerLogTools( query_id?: string; }>(`/app/${app_id}/logs/paginated`, { ...filters, - page_size: clampPageSize(page_size), + page_size: Math.min(clampPageSize(page_size ?? DEFAULT_LOG_PAGE_SIZE), MAX_LOG_PAGE_SIZE), date_range_start: normalizeStartDate(date_range_start), date_range_end: normalizeEndDate(date_range_end), }); From 94db39204cdb6f0b764daff7f0f4f568914e7808 Mon Sep 17 00:00:00 2001 From: Ali Farhadi Date: Mon, 26 Oct 2026 15:01:23 +0330 Subject: [PATCH 3/4] feat: Bugfender Claude Plugin - Added Claude connector conformance tests and release validation checklist --- CLAUDE_RELEASE_CHECKLIST.md | 53 +++++++ RELEASING.md | 2 + package.json | 1 + src/__tests__/claude-conformance.test.ts | 167 +++++++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 CLAUDE_RELEASE_CHECKLIST.md create mode 100644 src/__tests__/claude-conformance.test.ts diff --git a/CLAUDE_RELEASE_CHECKLIST.md b/CLAUDE_RELEASE_CHECKLIST.md new file mode 100644 index 0000000..41b51fa --- /dev/null +++ b/CLAUDE_RELEASE_CHECKLIST.md @@ -0,0 +1,53 @@ +# Claude Connector Release Checklist + +Use this checklist against staging before every Claude connector release. Do not replace it with a scheduled browser OAuth workflow: protocol tests should remain deterministic, while real-client OAuth acceptance is an explicit release step. + +## Automated checks + +- [ ] Run `pnpm test:claude` and confirm the Claude conformance suite passes. +- [ ] Run the full `pnpm check && pnpm test && pnpm build` verification. +- [ ] Start the hosted server with staging configuration and keep authentication enabled. +- [ ] Validate tool discovery with MCP Inspector: + + ```bash + pnpm dlx @modelcontextprotocol/inspector --cli \ + https://staging-mcp.example.com/mcp \ + --transport http \ + --method tools/list \ + --strict \ + --header "Authorization: Bearer $BUGFENDER_MCP_TEST_TOKEN" + ``` + +- [ ] Confirm Inspector lists every read tool and `update_issue_status` with its write/destructive annotations. +- [ ] Confirm discovery, registration, authorization, token, and refresh responses complete in under 10 seconds. +- [ ] Confirm maximum expected tool results remain below the configured HTTP limit and normalized tool-result limit. + +## OAuth conformance + +- [ ] A request without credentials returns `401` with `WWW-Authenticate` linking protected-resource metadata. +- [ ] Protected-resource metadata identifies the exact staging MCP resource and lists the intended Bugfender issuer first. +- [ ] Authorization-server metadata advertises S256 PKCE, supported scopes, revocation, and only registration methods that work. +- [ ] CIMD registration works with a public client and `token_endpoint_auth_method=none`. +- [ ] DCR works when `registration_endpoint` is advertised; otherwise confirm it is not advertised. +- [ ] Authorization Code with S256 succeeds and rejects invalid codes, reused codes, invalid verifiers, and redirect mismatches. +- [ ] Hosted callbacks work for `https://claude.ai/api/mcp/auth_callback` and `https://claude.com/api/mcp/auth_callback`. +- [ ] Claude Code callbacks work for dynamic ports on both `localhost` and `127.0.0.1`; other loopback hosts and invalid redirects fail. +- [ ] Access and refresh tokens remain bound to user, client, scopes, and the exact MCP resource/audience. +- [ ] Missing, malformed, expired, revoked, wrong-audience, and insufficient-scope access tokens fail safely. +- [ ] Refresh preserves or narrows scopes, never expands them, and follows the documented rotation/concurrency policy. +- [ ] Disconnect revokes the grant; reconnect and reauthorization create a usable new grant. +- [ ] Two simultaneous users retain isolated credentials and results. + +## Real Claude acceptance + +- [ ] Add the staging URL as a custom connector in Claude under **Customize → Connectors**. +- [ ] Complete OAuth in Claude.ai and verify the connector shows **Connected**. +- [ ] Verify every advertised tool appears under Tool permissions with the expected title and description. +- [ ] Run `who_am_i`, `list_apps`, and representative log, crash, device, issue, network, and feedback reads. +- [ ] Run `update_issue_status` on a dedicated test issue, approve the write, and verify the resulting status. +- [ ] Disconnect, verify revocation, reconnect, and repeat one read. +- [ ] Connect from Claude Code and exercise both `localhost` and `127.0.0.1` loopback callbacks on dynamic ports. +- [ ] Review server logs for timeouts, credential leakage, raw authorization data, cross-user state, or unexpected client-name authorization logic. +- [ ] Record the staging URL, build SHA, tester, date, tested Claude surfaces, and any known limitations in the release record. + +Reference: [Anthropic: Test your connector](https://claude.com/docs/connectors/building/testing). diff --git a/RELEASING.md b/RELEASING.md index 9afcc05..b6f25fc 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,6 +2,8 @@ ## Hosted MCP release +For Claude releases, complete [CLAUDE_RELEASE_CHECKLIST.md](./CLAUDE_RELEASE_CHECKLIST.md), including MCP Inspector validation and real custom-connector acceptance in Claude.ai and Claude Code. + The hosted service is the primary public distribution. Before publishing its registry metadata: diff --git a/package.json b/package.json index df312c9..c67f203 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "start": "node dist/index.js", "start:http": "node dist/http.js", "test": "vitest run", + "test:claude": "vitest run src/__tests__/claude-conformance.test.ts src/__tests__/http-server.test.ts src/__tests__/envelope.test.ts", "test:stdio": "node scripts/stdio-smoke.mjs", "check": "tsc --noEmit -p tsconfig.json", "release:check": "pnpm check && pnpm build && pnpm pack --pack-destination /tmp", diff --git a/src/__tests__/claude-conformance.test.ts b/src/__tests__/claude-conformance.test.ts new file mode 100644 index 0000000..adf4fd4 --- /dev/null +++ b/src/__tests__/claude-conformance.test.ts @@ -0,0 +1,167 @@ +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createHostedHttpServer, + type HostedHttpOptions, + type HostedHttpServer, +} from "../http-server.js"; + +const servers: HostedHttpServer[] = []; + +const expectedReadTools = [ + "count_devices", + "count_devices_with_logs", + "count_logs", + "get_app", + "get_app_summary", + "get_crash_details", + "get_crash_device_stats", + "get_crash_stats", + "get_crashes", + "get_feedback", + "get_issue", + "get_issue_device_stats", + "get_issue_devices", + "get_issue_stats", + "get_network_aggregates", + "get_network_details", + "get_sdk_snippet", + "list_app_versions", + "list_apps", + "list_issues", + "list_teams", + "search_devices", + "search_logs", + "who_am_i", +] as const; + +const expectedWriteTools = ["update_issue_status"] as const; + +function options(): HostedHttpOptions { + return { + apiUrl: "https://api.test", + host: "127.0.0.1", + port: 0, + maxRequestBytes: 1024 * 1024, + maxResponseBytes: 512 * 1024, + maxConcurrency: 10, + requestTimeoutMs: 2_000, + shutdownTimeoutMs: 500, + resourceUrl: "https://mcp.test/mcp", + authorizationServerUrl: "https://issuer.test", + resourceDocumentationUrl: "https://docs.test/mcp", + resourcePolicyUrl: "https://www.test/privacy", + resourceTermsUrl: "https://www.test/terms", + }; +} + +async function start(): Promise { + const hosted = createHostedHttpServer(options()); + servers.push(hosted); + await new Promise((resolve, reject) => { + hosted.server.once("error", reject); + hosted.server.listen(0, "127.0.0.1", resolve); + }); + return `http://127.0.0.1:${(hosted.server.address() as AddressInfo).port}`; +} + +async function mcp(baseUrl: string, body: unknown, authorization = "Bearer claude-test-token") { + return fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + Authorization: authorization, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +afterEach(async () => { + await Promise.allSettled(servers.splice(0).map((server) => server.shutdown())); +}); + +describe("Claude remote connector conformance", () => { + it.each(["claude-ai", "Anthropic", "claude-code", "future-claude-surface"])( + "initializes without authorizing by exact clientInfo.name: %s", + async (name) => { + const baseUrl = await start(); + const response = await mcp(baseUrl, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name, version: "1.0.0" }, + protocolVersion: "2025-06-18", + }, + }); + + expect(response.status).toBe(200); + const body = await response.json() as { result?: { serverInfo?: { name?: string } } }; + expect(body.result?.serverInfo?.name).toBe("bugfender"); + }, + ); + + it("discovers the complete read and write tool catalog", async () => { + const baseUrl = await start(); + const response = await mcp(baseUrl, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }); + const body = await response.json() as { + result?: { tools?: Array<{ name: string; annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean } }> }; + }; + const tools = body.result?.tools ?? []; + const byName = new Map(tools.map((tool) => [tool.name, tool])); + + expect([...byName.keys()].sort()).toEqual([...expectedReadTools, ...expectedWriteTools].sort()); + for (const name of expectedReadTools) { + expect(byName.get(name)?.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }); + } + for (const name of expectedWriteTools) { + expect(byName.get(name)?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + } + }); + + it("starts OAuth discovery with a 401 and the canonical resource metadata URL", async () => { + const baseUrl = await start(); + const response = await fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + }); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe( + 'Bearer resource_metadata="https://mcp.test/.well-known/oauth-protected-resource", scope="mcp:read"', + ); + }); + + it("rejects malformed bearer credentials with invalid_token", async () => { + const baseUrl = await start(); + const response = await mcp(baseUrl, {}, "Bearer invalid token with spaces"); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain('error="invalid_token"'); + }); + + it("advertises the intended issuer first and binds discovery to the exact MCP resource", async () => { + const baseUrl = await start(); + const response = await fetch(`${baseUrl}/.well-known/oauth-protected-resource`); + expect(await response.json()).toMatchObject({ + resource: "https://mcp.test/mcp", + authorization_servers: ["https://issuer.test"], + scopes_supported: ["mcp:read", "mcp:issues:write"], + }); + }); + + it("keeps discovery and tool-list responses within the hosted response budget", async () => { + const baseUrl = await start(); + const discovery = await fetch(`${baseUrl}/.well-known/oauth-protected-resource`); + const tools = await mcp(baseUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + expect(Buffer.byteLength(await discovery.text())).toBeLessThan(512 * 1024); + expect(Buffer.byteLength(await tools.text())).toBeLessThan(512 * 1024); + }); +}); From f5e47041b071114b742d52bf39250020f4df1d4f Mon Sep 17 00:00:00 2001 From: Ali Farhadi Date: Tue, 27 Oct 2026 14:09:07 +0330 Subject: [PATCH 4/4] feat: Bugfender Claude Plugin - Allowed HTTP resource and issuer URLs for loopback hosts - Migrated application listing to the newer /apps endpoint - Added configuration coverage for local and insecure non-loopback URLs --- src/__tests__/http-server.test.ts | 15 ++++++++++++--- src/http-server.ts | 20 ++++++++++++-------- src/tools/apps.ts | 2 +- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/__tests__/http-server.test.ts b/src/__tests__/http-server.test.ts index bd9f955..c53ce25 100644 --- a/src/__tests__/http-server.test.ts +++ b/src/__tests__/http-server.test.ts @@ -485,11 +485,20 @@ describe("hosted configuration", () => { ); expect(loadHostedHttpOptions({ BUGFENDER_MCP_RESOURCE_URL: "https://mcp.test/mcp" }).resourceUrl) .toBe("https://mcp.test/mcp"); + expect(loadHostedHttpOptions({ + BUGFENDER_MCP_RESOURCE_URL: "http://localhost:3002/mcp", + BUGFENDER_OAUTH_ISSUER: "http://127.0.0.1:3000", + })).toMatchObject({ + resourceUrl: "http://localhost:3002/mcp", + authorizationServerUrl: "http://127.0.0.1:3000", + }); expect(() => loadHostedHttpOptions({ BUGFENDER_MCP_RESOURCE_URL: "https://mcp.test" })) - .toThrow("BUGFENDER_MCP_RESOURCE_URL must be an HTTPS endpoint URL"); + .toThrow("BUGFENDER_MCP_RESOURCE_URL must be an HTTPS or loopback HTTP endpoint URL"); expect(() => loadHostedHttpOptions({ BUGFENDER_MCP_RESOURCE_URL: "https://mcp.test/mcp/" })) - .toThrow("BUGFENDER_MCP_RESOURCE_URL must be an HTTPS endpoint URL"); + .toThrow("BUGFENDER_MCP_RESOURCE_URL must be an HTTPS or loopback HTTP endpoint URL"); + expect(() => loadHostedHttpOptions({ BUGFENDER_MCP_RESOURCE_URL: "http://mcp.test/mcp" })) + .toThrow("BUGFENDER_MCP_RESOURCE_URL must be an HTTPS or loopback HTTP endpoint URL"); expect(() => loadHostedHttpOptions({ BUGFENDER_OAUTH_ISSUER: "http://dashboard.test" })) - .toThrow("BUGFENDER_OAUTH_ISSUER must be an HTTPS origin"); + .toThrow("BUGFENDER_OAUTH_ISSUER must be an HTTPS or loopback HTTP origin"); }); }); diff --git a/src/http-server.ts b/src/http-server.ts index 14027cd..3fa6ae7 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -109,7 +109,11 @@ function positiveInteger(value: string | undefined, fallback: number, name: stri return parsed; } -function httpsOrigin(value: string, name: string): string { +function isLoopbackHostname(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; +} + +function secureOrigin(value: string, name: string): string { let parsed: URL; try { parsed = new URL(value); @@ -117,19 +121,19 @@ function httpsOrigin(value: string, name: string): string { throw new Error(`${name} must be a valid URL`); } if ( - parsed.protocol !== "https:" + (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopbackHostname(parsed.hostname))) || parsed.username !== "" || parsed.password !== "" || parsed.pathname !== "/" || parsed.search !== "" || parsed.hash !== "" ) { - throw new Error(`${name} must be an HTTPS origin without credentials, path, query, or fragment`); + throw new Error(`${name} must be an HTTPS or loopback HTTP origin without credentials, path, query, or fragment`); } return parsed.origin; } -function httpsResourceUrl(value: string, name: string): string { +function secureResourceUrl(value: string, name: string): string { let parsed: URL; try { parsed = new URL(value); @@ -137,7 +141,7 @@ function httpsResourceUrl(value: string, name: string): string { throw new Error(`${name} must be a valid URL`); } if ( - parsed.protocol !== "https:" + (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopbackHostname(parsed.hostname))) || parsed.username !== "" || parsed.password !== "" || parsed.search !== "" @@ -145,7 +149,7 @@ function httpsResourceUrl(value: string, name: string): string { || parsed.pathname === "/" || parsed.pathname.endsWith("/") ) { - throw new Error(`${name} must be an HTTPS endpoint URL without credentials, a trailing slash, query, or fragment`); + throw new Error(`${name} must be an HTTPS or loopback HTTP endpoint URL without credentials, a trailing slash, query, or fragment`); } return parsed.toString(); } @@ -180,11 +184,11 @@ export function loadHostedHttpOptions(env: NodeJS.ProcessEnv = process.env): Hos DEFAULT_SHUTDOWN_TIMEOUT_MS, "BUGFENDER_MCP_SHUTDOWN_TIMEOUT_MS", ), - resourceUrl: httpsResourceUrl( + resourceUrl: secureResourceUrl( env.BUGFENDER_MCP_RESOURCE_URL || DEFAULT_RESOURCE_URL, "BUGFENDER_MCP_RESOURCE_URL", ), - authorizationServerUrl: httpsOrigin( + authorizationServerUrl: secureOrigin( env.BUGFENDER_OAUTH_ISSUER || DEFAULT_AUTHORIZATION_SERVER_URL, "BUGFENDER_OAUTH_ISSUER", ), diff --git a/src/tools/apps.ts b/src/tools/apps.ts index 01c97fe..6c806c0 100644 --- a/src/tools/apps.ts +++ b/src/tools/apps.ts @@ -19,7 +19,7 @@ export function registerAppTools(server: McpServer, context: ServerContext): voi annotations: readOnlyToolAnnotations, _meta: readOnlyOAuthMetadata, }, () => - handleTool(context, async () => ok(omitCredentials(await context.client.get("/app/")))), + handleTool(context, async () => ok(omitCredentials(await context.client.get("/apps")))), ); server.registerTool("get_app", {