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
68 changes: 68 additions & 0 deletions .github/workflows/claude.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Claude Code

on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened]
pull_request_review:
types: [submitted]
pull_request_target:
types: [opened, synchronize]
Comment on lines +12 to +13
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: pull_request_target with PR head checkout creates a "pwn request" vulnerability.

Using pull_request_target grants write permissions and access to secrets, but checking out github.event.pull_request.head.sha (the untrusted PR code) allows malicious PRs to execute arbitrary code with those elevated privileges. An attacker could modify workflow files or exfiltrate secrets.

Safer alternatives:

  1. Use pull_request event instead (runs in PR context without secrets access)
  2. If pull_request_target is required, only checkout the base branch, not the PR head
  3. Add an explicit approval step before running on external PRs
🔒 Recommended fix: Use pull_request event or avoid checking out PR head
-  pull_request_target:
-    types: [opened, synchronize]
+  pull_request:
+    types: [opened, synchronize]

Or if you need secrets access, don't checkout PR head:

      - name: Checkout repository
        uses: actions/checkout@v4
-        with:
-          # This correctly checks out the PR's head commit for pull_request_target events.
-          ref: ${{ github.event.pull_request.head.sha }}

Also applies to: 38-42

🤖 Prompt for AI Agents
In @.github/workflows/claude.yaml around lines 12 - 13, The workflow uses the
pull_request_target event combined with checking out the PR head
(github.event.pull_request.head.sha), which lets untrusted PR code run with
elevated permissions; change the workflow event to pull_request or, if
pull_request_target is required, update the checkout step to use the base branch
(github.event.pull_request.base.sha) instead of the PR head, or add an explicit
human-approval job that gates any steps requiring secrets; search for the token
usage and the checkout action (references: pull_request_target,
github.event.pull_request.head.sha, github.event.pull_request.base.sha) and
implement one of these safer alternatives.


jobs:
claude:
# This simplified condition is more robust and correctly checks permissions.
if: >
(contains(github.event.comment.body, '@claude') ||
contains(github.event.review.body, '@claude') ||
contains(github.event.issue.body, '@claude') ||
contains(github.event.pull_request.body, '@claude')) &&
(github.event.sender.type == 'User' && (
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
))
Comment on lines +18 to +27
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Condition logic is incomplete for pull_request_target events.

The author_association check only references github.event.comment.author_association, which is undefined for pull_request_target opened/synchronize events (there's no comment). This causes the job to never run for those triggers even when @claude is in the PR body.

If you intend to support PR-triggered runs, you need to check associations conditionally:

🛠️ Proposed fix to handle different event types
     if: >
       (contains(github.event.comment.body, '@claude') ||
       contains(github.event.review.body, '@claude') ||
       contains(github.event.issue.body, '@claude') ||
       contains(github.event.pull_request.body, '@claude')) &&
-      (github.event.sender.type == 'User' && (
-        github.event.comment.author_association == 'OWNER' ||
-        github.event.comment.author_association == 'MEMBER' ||
-        github.event.comment.author_association == 'COLLABORATOR'
+      github.event.sender.type == 'User' && (
+        github.event.comment.author_association == 'OWNER' ||
+        github.event.comment.author_association == 'MEMBER' ||
+        github.event.comment.author_association == 'COLLABORATOR' ||
+        github.event.review.author_association == 'OWNER' ||
+        github.event.review.author_association == 'MEMBER' ||
+        github.event.review.author_association == 'COLLABORATOR' ||
+        github.event.issue.author_association == 'OWNER' ||
+        github.event.issue.author_association == 'MEMBER' ||
+        github.event.issue.author_association == 'COLLABORATOR' ||
+        github.event.pull_request.author_association == 'OWNER' ||
+        github.event.pull_request.author_association == 'MEMBER' ||
+        github.event.pull_request.author_association == 'COLLABORATOR'
-      ))
+      )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if: >
(contains(github.event.comment.body, '@claude') ||
contains(github.event.review.body, '@claude') ||
contains(github.event.issue.body, '@claude') ||
contains(github.event.pull_request.body, '@claude')) &&
(github.event.sender.type == 'User' && (
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
))
if: >
(contains(github.event.comment.body, '@claude') ||
contains(github.event.review.body, '@claude') ||
contains(github.event.issue.body, '@claude') ||
contains(github.event.pull_request.body, '@claude')) &&
github.event.sender.type == 'User' && (
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR' ||
github.event.review.author_association == 'OWNER' ||
github.event.review.author_association == 'MEMBER' ||
github.event.review.author_association == 'COLLABORATOR' ||
github.event.issue.author_association == 'OWNER' ||
github.event.issue.author_association == 'MEMBER' ||
github.event.issue.author_association == 'COLLABORATOR' ||
github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR'
)
🤖 Prompt for AI Agents
In @.github/workflows/claude.yaml around lines 18 - 27, The conditional in the
workflow only checks github.event.comment.author_association, so jobs triggered
by pull_request_target events (where the PR body contains "@claude") never pass
because there is no comment object; update the if expression to validate author
association for the correct event payloads by adding checks for
github.event.pull_request.author_association and
github.event.review.author_association (in addition to
github.event.comment.author_association and
github.event.issue.author_association) or conditionally select the association
based on event type (e.g., check github.event_name == 'pull_request' ?
github.event.pull_request.author_association :
github.event.comment.author_association) while preserving the existing
sender.type == 'User' and allowed associations (OWNER, MEMBER, COLLABORATOR) so
PR-open/synchronize events will run when `@claude` is in the PR body.

runs-on: ubuntu-latest
permissions:
# CRITICAL: Write permissions are required for the action to push branches and update issues/PRs.
contents: write
pull-requests: write
issues: write
id-token: write # Required for OIDC token exchange
actions: read # Required for Claude to read CI results on PRs

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
# This correctly checks out the PR's head commit for pull_request_target events.
ref: ${{ github.event.pull_request.head.sha }}

- name: Create Claude settings file
run: |
mkdir -p /home/runner/.claude
cat > /home/runner/.claude/settings.json << 'EOF'
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"ANTHROPIC_AUTH_TOKEN": "${{ secrets.CUSTOM_ENDPOINT_API_KEY }}"
}
}
EOF

- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
# Still need this to satisfy the action's validation
anthropic_api_key: ${{ secrets.CUSTOM_ENDPOINT_API_KEY }}

# Use the same variable names as your local setup
settings: '{"env": {"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", "ANTHROPIC_AUTH_TOKEN": "${{ secrets.CUSTOM_ENDPOINT_API_KEY }}"}}'

track_progress: true
claude_args: |
--allowedTools "Bash,Edit,Read,Write,Glob,Grep"
210 changes: 182 additions & 28 deletions ctx-mcp-bridge/src/mcpServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { loadAnyAuthEntry, loadAuthEntry, readConfig, saveAuthEntry } from "./authConfig.js";
import { maybeRemapToolArgs, maybeRemapToolResult } from "./resultPathMapping.js";
import * as oauthHandler from "./oauthHandler.js";
Expand Down Expand Up @@ -58,14 +64,46 @@ function dedupeTools(tools) {
return out;
}

function dedupeResources(resources) {
const seen = new Set();
const out = [];
for (const resource of resources) {
const uri = resource && typeof resource.uri === "string" ? resource.uri : "";
if (!uri || seen.has(uri)) {
continue;
}
seen.add(uri);
out.push(resource);
}
return out;
}

function dedupeResourceTemplates(templates) {
const seen = new Set();
const out = [];
for (const template of templates) {
const uri =
template && typeof template.uriTemplate === "string"
? template.uriTemplate
: "";
if (!uri || seen.has(uri)) {
continue;
}
seen.add(uri);
out.push(template);
}
return out;
}

async function listMemoryTools(client) {
if (!client) {
return [];
}
try {
const timeoutMs = getBridgeListTimeoutMs();
const remote = await withTimeout(
client.listTools(),
5000,
timeoutMs,
"memory tools/list",
);
return Array.isArray(remote?.tools) ? remote.tools.slice() : [];
Expand All @@ -75,6 +113,42 @@ async function listMemoryTools(client) {
}
}

async function listResourcesSafe(client, label) {
if (!client) {
return [];
}
try {
const timeoutMs = getBridgeListTimeoutMs();
const remote = await withTimeout(
client.listResources(),
timeoutMs,
`${label} resources/list`,
);
return Array.isArray(remote?.resources) ? remote.resources.slice() : [];
} catch (err) {
debugLog(`[ctxce] Error calling ${label} resources/list: ` + String(err));
return [];
}
}

async function listResourceTemplatesSafe(client, label) {
if (!client) {
return [];
}
try {
const timeoutMs = getBridgeListTimeoutMs();
const remote = await withTimeout(
client.listResourceTemplates(),
timeoutMs,
`${label} resources/templates/list`,
);
return Array.isArray(remote?.resourceTemplates) ? remote.resourceTemplates.slice() : [];
} catch (err) {
debugLog(`[ctxce] Error calling ${label} resources/templates/list: ` + String(err));
return [];
}
}

function withTimeout(promise, ms, label) {
return new Promise((resolve, reject) => {
let settled = false;
Expand Down Expand Up @@ -125,6 +199,25 @@ function getBridgeToolTimeoutMs() {
}
}

function getBridgeListTimeoutMs() {
try {
// Keep list operations on a separate budget from tools/call.
// Some streamable-http clients (including Codex) probe tools/resources early,
// and a short timeout here can make the bridge appear unavailable.
const raw = process.env.CTXCE_LIST_TIMEOUT_MSEC;
if (!raw) {
return 60000;
}
const parsed = Number.parseInt(String(raw), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 60000;
}
return parsed;
} catch {
return 60000;
}
}

function selectClientForTool(name, indexerClient, memoryClient) {
if (!name) {
return indexerClient;
Expand Down Expand Up @@ -651,6 +744,7 @@ async function createBridgeServer(options) {
{
capabilities: {
tools: {},
resources: {},
},
},
);
Expand All @@ -664,9 +758,10 @@ async function createBridgeServer(options) {
if (!indexerClient) {
throw new Error("Indexer MCP client not initialized");
}
const timeoutMs = getBridgeListTimeoutMs();
remote = await withTimeout(
indexerClient.listTools(),
10000,
timeoutMs,
"indexer tools/list",
);
} catch (err) {
Expand All @@ -693,6 +788,57 @@ async function createBridgeServer(options) {
return { tools };
});

server.setRequestHandler(ListResourcesRequestSchema, async () => {
// Proxy resource discovery/read-through so clients that use MCP resources
// (not only tools) can access upstream indexer/memory resources directly.
await initializeRemoteClients(false);
const indexerResources = await listResourcesSafe(indexerClient, "indexer");
const memoryResources = await listResourcesSafe(memoryClient, "memory");
const resources = dedupeResources([...indexerResources, ...memoryResources]);
debugLog(`[ctxce] resources/list: returning ${resources.length} resources`);
return { resources };
});

server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
await initializeRemoteClients(false);
const indexerTemplates = await listResourceTemplatesSafe(indexerClient, "indexer");
const memoryTemplates = await listResourceTemplatesSafe(memoryClient, "memory");
const resourceTemplates = dedupeResourceTemplates([...indexerTemplates, ...memoryTemplates]);
debugLog(`[ctxce] resources/templates/list: returning ${resourceTemplates.length} templates`);
return { resourceTemplates };
});

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
await initializeRemoteClients(false);
const params = request.params || {};
const timeoutMs = getBridgeToolTimeoutMs();
const uri =
params && typeof params.uri === "string" ? params.uri : "<missing-uri>";
debugLog(`[ctxce] resources/read: ${uri}`);

const tryRead = async (client, label) => {
if (!client) {
return null;
}
try {
return await client.readResource(params, { timeout: timeoutMs });
} catch (err) {
debugLog(`[ctxce] resources/read failed on ${label}: ` + String(err));
return null;
}
};

const indexerResult = await tryRead(indexerClient, "indexer");
if (indexerResult) {
return indexerResult;
}
const memoryResult = await tryRead(memoryClient, "memory");
if (memoryResult) {
return memoryResult;
}
throw new Error(`Resource ${uri} not available on any configured MCP server`);
});

// tools/call → proxied to indexer or memory server
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const params = request.params || {};
Expand Down Expand Up @@ -843,6 +989,13 @@ export async function runHttpMcpServer(options) {
typeof options.port === "number"
? options.port
: Number.parseInt(process.env.CTXCE_HTTP_PORT || "30810", 10) || 30810;
// TODO(auth): replace this boolean toggle with explicit auth modes (none|required).
// In required mode, enforce Bearer auth on /mcp with consistent 401 challenges and
// only advertise OAuth metadata/endpoints when authentication is mandatory.
// In local/dev mode, leaving OAuth discovery off avoids clients entering an
// unnecessary OAuth path for otherwise unauthenticated bridge usage.
const oauthEnabled = String(process.env.CTXCE_ENABLE_OAUTH || "").trim().toLowerCase();
const oauthEndpointsEnabled = oauthEnabled === "1" || oauthEnabled === "true" || oauthEnabled === "yes";

const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
Expand All @@ -865,34 +1018,36 @@ export async function runHttpMcpServer(options) {
// OAuth 2.0 Endpoints (RFC9728 Protected Resource Metadata + RFC7591)
// ================================================================

// OAuth metadata endpoint (RFC9728)
if (parsedUrl.pathname === "/.well-known/oauth-authorization-server") {
oauthHandler.handleOAuthMetadata(req, res, issuerUrl);
return;
}
if (oauthEndpointsEnabled) {
// OAuth metadata endpoint (RFC9728)
if (parsedUrl.pathname === "/.well-known/oauth-authorization-server") {
oauthHandler.handleOAuthMetadata(req, res, issuerUrl);
return;
}

// OAuth Dynamic Client Registration endpoint (RFC7591)
if (parsedUrl.pathname === "/oauth/register" && req.method === "POST") {
oauthHandler.handleOAuthRegister(req, res);
return;
}
// OAuth Dynamic Client Registration endpoint (RFC7591)
if (parsedUrl.pathname === "/oauth/register" && req.method === "POST") {
oauthHandler.handleOAuthRegister(req, res);
return;
}

// OAuth authorize endpoint
if (parsedUrl.pathname === "/oauth/authorize") {
oauthHandler.handleOAuthAuthorize(req, res, parsedUrl.searchParams);
return;
}
// OAuth authorize endpoint
if (parsedUrl.pathname === "/oauth/authorize") {
oauthHandler.handleOAuthAuthorize(req, res, parsedUrl.searchParams);
return;
}

// Store session endpoint (helper for login page)
if (parsedUrl.pathname === "/oauth/store-session" && req.method === "POST") {
oauthHandler.handleOAuthStoreSession(req, res);
return;
}
// Store session endpoint (helper for login page)
if (parsedUrl.pathname === "/oauth/store-session" && req.method === "POST") {
oauthHandler.handleOAuthStoreSession(req, res);
return;
}

// OAuth token endpoint
if (parsedUrl.pathname === "/oauth/token" && req.method === "POST") {
oauthHandler.handleOAuthToken(req, res);
return;
// OAuth token endpoint
if (parsedUrl.pathname === "/oauth/token" && req.method === "POST") {
oauthHandler.handleOAuthToken(req, res);
return;
}
}

// ================================================================
Expand Down Expand Up @@ -1058,4 +1213,3 @@ function detectRepoName(workspace, config) {
const leaf = workspace ? path.basename(workspace) : "";
return leaf && SLUGGED_REPO_RE.test(leaf) ? leaf : null;
}

Loading