Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CLAUDE_RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -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).
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
167 changes: 167 additions & 0 deletions src/__tests__/claude-conformance.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const hosted = createHostedHttpServer(options());
servers.push(hosted);
await new Promise<void>((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);
});
});
57 changes: 46 additions & 11 deletions src/__tests__/envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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",
},
});
});
});

Expand All @@ -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 () => {
Expand Down
Loading
Loading