Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ RESEND_FROM_EMAIL=reset@kody.dev
# SENTRY_ORG=your-org-slug
# SENTRY_PROJECT=your-project-slug

# GitHub (optional; `bun run dev` sets a mock API URL + token for the `github_rest` capability)
# GitHub (optional; `bun run dev` sets a mock API URL + token for the `github_rest` + `github_graphql` capabilities)
# For production, use a fine-grained PAT for the `kody-bot` account synced as the Worker secret
# `GITHUB_TOKEN` (in GitHub Actions use repo secret `KODY_GITHUB_TOKEN`; deploy maps it to
# `GITHUB_TOKEN`).
Expand Down
5 changes: 3 additions & 2 deletions docs/agents/mock-api-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ it can also be deployed alongside the main app.
4. In `cli.ts`, start the mock Worker during `bun run dev` (via `wrangler dev`)
and set `ACME_API_BASE_URL` to the mock Worker origin.

See `mock-servers/github/` for the GitHub REST subset mock used by the
`github_rest` capability (started by `bun run dev` unless `SKIP_GITHUB_MOCK=1`).
See `mock-servers/github/` for the GitHub REST + GraphQL subset mock used by the
`github_rest` and `github_graphql` capabilities (started by `bun run dev` unless
`SKIP_GITHUB_MOCK=1`).

See `mock-servers/cursor/` for a small
[Cursor Cloud Agents API](https://cursor.com/docs/cloud-agent/api/endpoints)
Expand Down
6 changes: 3 additions & 3 deletions docs/agents/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ Quick notes for getting a local kody environment running.
- `bun run dev` (starts mock API servers automatically and sets
`RESEND_API_BASE_URL`, `AI_MODE=mock`, `AI_MOCK_BASE_URL`, and (unless
`SKIP_GITHUB_MOCK=1`) `GITHUB_API_BASE_URL` + `GITHUB_TOKEN` to the local
GitHub mock Worker for the `github_rest` capability, and (unless
`SKIP_CURSOR_MOCK=1`) `CURSOR_API_BASE_URL` + `CURSOR_API_KEY` to the local
Cursor Cloud mock Worker for the `cursor_cloud_rest` capability).
GitHub mock Worker for the `github_rest` + `github_graphql` capabilities, and
(unless `SKIP_CURSOR_MOCK=1`) `CURSOR_API_BASE_URL` + `CURSOR_API_KEY` to the
local Cursor Cloud mock Worker for the `cursor_cloud_rest` capability).
- MCP **`search`** uses a deterministic offline ranker in tests and when
`WRANGLER_IS_LOCAL_DEV` is set (no Vectorize / Workers AI embedding calls
required for `bun test` or unauthenticated local runs). Production uses
Expand Down
22 changes: 12 additions & 10 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,22 @@ Optional Worker secret and vars (see `types/env-schema.ts` and
- `SENTRY_TRACES_SAMPLE_RATE` — optional `0`–`1`; defaults to **`1.0`** (sample
all traces). Set lower (e.g. `0.1`) for higher traffic or Sentry quota.

## GitHub (`github_rest` capability)
## GitHub (`github_rest` + `github_graphql` capabilities)

Optional Worker secrets/vars (see `types/env-schema.ts` and
`mcp/github/github-rest-client.ts`):
Optional Worker secrets/vars (see `types/env-schema.ts`,
`mcp/github/github-rest-client.ts`, and `mcp/github/github-graphql-client.ts`):

- `GITHUB_TOKEN` — Bearer token for `api.github.com` (fine-grained PAT
recommended) for the `kody-bot` account. GitHub REST calls act as that bot
identity rather than as `kentcdodds`. When unset, `github_rest` fails fast
with a setup hint. In GitHub Actions you cannot create a repository secret
named `GITHUB_*`; production deploy reads **`KODY_GITHUB_TOKEN`** and syncs it
to this Worker secret (see `docs/setup-manifest.md`).
- `GITHUB_API_BASE_URL` — REST API base URL; defaults to
recommended) for the `kody-bot` account. GitHub REST and GraphQL calls act as
that bot identity rather than as `kentcdodds`. When unset, `github_rest` and
`github_graphql` fail fast with a setup hint. In GitHub Actions you cannot
create a repository secret named `GITHUB_*`; production deploy reads
**`KODY_GITHUB_TOKEN`** and syncs it to this Worker secret (see
`docs/setup-manifest.md`).
- `GITHUB_API_BASE_URL` — GitHub API base URL; defaults to
`https://api.github.com` when unset. Local `bun run dev` sets this to the
GitHub mock Worker unless `SKIP_GITHUB_MOCK=1`.
GitHub mock Worker unless `SKIP_GITHUB_MOCK=1`. GraphQL requests target
`${GITHUB_API_BASE_URL}/graphql`.

## MCP capability search (Vectorize + Workers AI)

Expand Down
8 changes: 5 additions & 3 deletions docs/setup-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ Local development uses `.env`, which Wrangler loads automatically:
- `APP_COMMIT_SHA` (used as the Sentry **release** when present, in addition to
`/health` versioning)
- `GITHUB_TOKEN` (optional Worker secret; fine-grained PAT for `kody-bot` used
by the `github_rest` MCP capability)
by the `github_rest` and `github_graphql` MCP capabilities)
- `GITHUB_API_BASE_URL` (optional; defaults to `https://api.github.com`. Local
`bun run dev` targets the GitHub mock unless `SKIP_GITHUB_MOCK=1`)
`bun run dev` targets the GitHub mock unless `SKIP_GITHUB_MOCK=1`. GraphQL
requests hit `${GITHUB_API_BASE_URL}/graphql`.)
- `CURSOR_API_KEY` (optional Worker secret; Cursor Cloud Agents API key for the
`cursor_cloud_rest` MCP capability)
- `CURSOR_API_BASE_URL` (optional; defaults to `https://api.cursor.com`. Local
Expand Down Expand Up @@ -105,7 +106,8 @@ Configure these GitHub Actions secrets and variables for workflows:
- `RESEND_FROM_EMAIL` (optional, required to send via Resend)
- `SENTRY_DSN` (optional; create a JavaScript/Cloudflare project in Sentry and
paste the DSN; syncs to the Worker as a secret when set in GitHub Actions)
- `KODY_GITHUB_TOKEN` (optional; bot token for the `github_rest` capability —
- `KODY_GITHUB_TOKEN` (optional; bot token for the `github_rest` and
`github_graphql` capabilities —
see below; deploy maps this to the Worker secret `GITHUB_TOKEN` because GitHub
Actions forbids repository secrets named `GITHUB_*`)
- `CURSOR_API_KEY` (optional; Cursor Cloud API key for `cursor_cloud_rest`;
Expand Down
49 changes: 49 additions & 0 deletions mcp/capabilities/coding/coding-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@ import { expect, test } from 'bun:test'
import getPort from 'get-port'
import { setTimeout as delay } from 'node:timers/promises'
import { cursorCloudRestCapability } from './cursor-cloud-rest.ts'
import { githubGraphqlCapability } from './github-graphql.ts'
import { githubRestCapability } from './github-rest.ts'

const workerConfig = 'mock-servers/github/wrangler.jsonc'
const cursorWorkerConfig = 'mock-servers/cursor/wrangler.jsonc'
const bunBin = process.execPath
const projectRoot = process.cwd()
const timeoutMs = 60_000
const graphqlQuery = `query RepoPull($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
number
title
}
}
}`

function captureOutput(stream: ReadableStream<Uint8Array> | null) {
if (!stream) return
Expand Down Expand Up @@ -199,6 +208,46 @@ test(
{ timeout: timeoutMs },
)

test(
'github_graphql returns data from mock GitHub',
async () => {
const token = 'coding-graphql-token'
await using mock = await startGithubMock(token)
const ctx = mockContext(mock.origin, mock.token)
const result = await githubGraphqlCapability.handler(
{
query: graphqlQuery,
variables: { owner: 'kentcdodds', name: 'kody', number: 42 },
},
ctx,
)
expect(result.status).toBe(200)
const data = result.data as {
repository?: { pullRequest?: { number?: number } }
}
expect(data.repository?.pullRequest?.number).toBe(42)
},
{ timeout: timeoutMs },
)

test(
'github_graphql rejects empty query',
async () => {
const token = 'coding-graphql-reject-token'
await using mock = await startGithubMock(token)
const ctx = mockContext(mock.origin, mock.token)
await expect(
githubGraphqlCapability.handler(
{
query: '',
},
ctx,
),
).rejects.toThrow('expected string to have >=1 characters')
},
{ timeout: timeoutMs },
)

test(
'github_rest rejects absolute URLs in path',
async () => {
Expand Down
6 changes: 5 additions & 1 deletion mcp/capabilities/coding/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@ import { defineDomain } from '../define-domain.ts'
import { capabilityDomainNames } from '../domain-metadata.ts'
import { cursorCloudAgentDocsCapability } from './cursor-cloud-agent-docs.ts'
import { cursorCloudRestCapability } from './cursor-cloud-rest.ts'
import { githubGraphqlApiDocsCapability } from './github-graphql-api-docs.ts'
import { githubGraphqlCapability } from './github-graphql.ts'
import { githubRestApiDocsCapability } from './github-rest-api-docs.ts'
import { githubRestCapability } from './github-rest.ts'

export const codingDomain = defineDomain({
name: capabilityDomainNames.coding,
description:
'Software work such as GitHub repository actions, issues, pull requests, Cursor Cloud Agents API calls, public REST and Cloud Agents documentation fetch (markdown), and coding-agent workflows.',
'Software work such as GitHub repository actions, issues, pull requests, GitHub GraphQL queries, Cursor Cloud Agents API calls, public REST/GraphQL/Cloud Agents documentation fetch (markdown), and coding-agent workflows.',
capabilities: [
githubRestCapability,
githubRestApiDocsCapability,
githubGraphqlCapability,
githubGraphqlApiDocsCapability,
cursorCloudRestCapability,
cursorCloudAgentDocsCapability,
],
Expand Down
38 changes: 38 additions & 0 deletions mcp/capabilities/coding/github-docs-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
type GithubDocsPathOptions = {
path: string
localePrefix: RegExp
apiLabel: string
localePrefixExample: string
examplePath: string
docsUrl: string
}

export function assertGithubDocsPath({
path,
localePrefix,
apiLabel,
localePrefixExample,
examplePath,
docsUrl,
}: GithubDocsPathOptions) {
const trimmed = path.trim()
if (!trimmed.startsWith('/')) {
throw new Error(
`path must start with \`/\` and must not include a host (for example use \`${examplePath}\`).`,
)
}
if (!localePrefix.test(trimmed)) {
throw new Error(
`path must start with a locale ${apiLabel} prefix such as \`${localePrefixExample}\` (see ${docsUrl}).`,
)
}
if (trimmed.includes('..')) {
throw new Error('path must not contain `..` segments.')
}
if (/[\s#]/.test(trimmed)) {
throw new Error('path contains disallowed characters.')
}
if (trimmed.length > 2048) {
throw new Error('path exceeds maximum length.')
}
}
77 changes: 77 additions & 0 deletions mcp/capabilities/coding/github-graphql-api-docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { z } from 'zod'
import { defineDomainCapability } from '../define-domain-capability.ts'
import { capabilityDomainNames } from '../domain-metadata.ts'
import { type CapabilityContext } from '../types.ts'
import { fetchMarkdownPreferredDoc } from './fetch-markdown-doc.ts'
import { assertGithubDocsPath } from './github-docs-path.ts'

const GITHUB_DOCS_ORIGIN = 'https://docs.github.com'

const localeGraphqlPrefix =
/^\/(?:[a-z]{2}|[a-z]{2}-[a-z]{2})\/graphql(?:\/|$)/

const inputSchema = z.object({
path: z
.string()
.min(1)
.describe(
'Path on docs.github.com for GraphQL reference pages, starting with `/{locale}/graphql/` (no host). Example: `/en/graphql/overview`.',
),
})

const outputSchema = z.object({
status: z.number().describe('HTTP status from docs.github.com.'),
contentType: z
.string()
.nullable()
.describe('Response Content-Type header, if present.'),
markdownTokenEstimate: z
.string()
.nullable()
.describe(
'Optional x-markdown-tokens header when the response is markdown.',
),
body: z
.string()
.describe(
'Response body (typically markdown when Content-Type is text/markdown).',
),
})

function assertGithubGraphqlDocsPath(path: string) {
assertGithubDocsPath({
path,
localePrefix: localeGraphqlPrefix,
apiLabel: 'GraphQL',
localePrefixExample: '/en/graphql/',
examplePath: '/en/graphql/overview',
docsUrl: 'https://docs.github.com/en/graphql',
})
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

export const githubGraphqlApiDocsCapability = defineDomainCapability(
capabilityDomainNames.coding,
{
name: 'github_graphql_api_docs',
description:
'Read-only fetch of GitHub GraphQL API reference documentation from docs.github.com with Accept: text/markdown so responses are markdown when available.',
keywords: [
'github',
'graphql',
'documentation',
'docs',
'markdown',
'api reference',
],
readOnly: true,
idempotent: true,
destructive: false,
inputSchema,
outputSchema,
async handler(args, _ctx: CapabilityContext) {
assertGithubGraphqlDocsPath(args.path)
const url = new URL(args.path.trim(), GITHUB_DOCS_ORIGIN).toString()
return fetchMarkdownPreferredDoc(url)
},
},
)
86 changes: 86 additions & 0 deletions mcp/capabilities/coding/github-graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { z } from 'zod'
import { createGitHubGraphqlClient } from '#mcp/github/github-graphql-client.ts'
import { defineDomainCapability } from '../define-domain-capability.ts'
import { capabilityDomainNames } from '../domain-metadata.ts'
import { type CapabilityContext } from '../types.ts'

const inputSchema = z.object({
query: z
.string()
.min(1)
Comment on lines +8 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Zod 4, does z.string().min(1)accept whitespace-only strings unless.trim()is applied before.min(1)?

💡 Result:

Yes, in Zod 4, z.string.min(1) accepts whitespace-only strings like " " because it checks the string length after any prior transforms, and without .trim, the length of " " is 1, which meets the minimum. To reject whitespace-only strings, apply .trim before .min(1), as in z.string.trim.min(1), which trims whitespace first, making " ".length become 0 and fail the check.

Citations:


🏁 Script executed:

cat -n mcp/capabilities/coding/github-graphql.ts | sed -n '1,70p'

Repository: kentcdodds/kody

Length of output: 2554


Whitespace-only query and operationName bypass schema validation.

query: ' ' and operationName: ' ' pass .min(1) checks because they have length ≥ 1, then get trimmed to '' at runtime (lines 56, 58) instead of failing fast during validation.

Apply .trim() before .min(1) in the schema:

Proposed fix
 const inputSchema = z.object({
 	query: z
 		.string()
+		.trim()
 		.min(1)
 		.describe(
 			'GraphQL query or mutation string to send to https://api.github.com/graphql.',
 		),
 	variables: z
 		.record(z.string(), z.unknown())
 		.optional()
 		.describe('Optional GraphQL variables object (JSON).'),
 	operationName: z
 		.string()
+		.trim()
 		.min(1)
 		.optional()
 		.describe('Optional GraphQL operation name.'),
 })
 const response = await client.query({
-	query: args.query.trim(),
+	query: args.query,
 	variables: args.variables,
-	operationName: args.operationName?.trim() || undefined,
+	operationName: args.operationName || undefined,
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mcp/capabilities/coding/github-graphql.ts` around lines 8 - 10, The Zod
schema for the GraphQL request allows whitespace-only values because .min(1)
runs before trimming; update the schema fields named query and operationName in
github-graphql.ts to call .trim() before .min(1) (e.g., replace
z.string().min(1) with z.string().trim().min(1)) so whitespace-only inputs fail
validation rather than being trimmed to empty at runtime.

.describe(
'GraphQL query or mutation string to send to https://api.github.com/graphql.',
),
variables: z
.record(z.string(), z.unknown())
.optional()
.describe('Optional GraphQL variables object (JSON).'),
operationName: z
.string()
.min(1)
.optional()
.describe('Optional GraphQL operation name.'),
})

const outputSchema = z.object({
status: z.number().describe('HTTP status code from GitHub.'),
data: z
.unknown()
.nullable()
.describe('GraphQL data payload when present.'),
errors: z
.array(z.unknown())
.nullable()
.describe('GraphQL errors array when present.'),
extensions: z
.unknown()
.nullable()
.describe('Optional GraphQL extensions payload.'),
})

export const githubGraphqlCapability = defineDomainCapability(
capabilityDomainNames.coding,
{
name: 'github_graphql',
description:
'Low-level GitHub GraphQL API access. Sends the provided query/mutation with optional variables and returns data/errors from the response.',
keywords: ['github', 'graphql', 'api', 'query', 'mutation', 'bot'],
readOnly: false,
idempotent: false,
destructive: true,
inputSchema,
outputSchema,
async handler(args, ctx: CapabilityContext) {
const client = createGitHubGraphqlClient(ctx.env)
const response = await client.query({
query: args.query.trim(),
variables: args.variables,
operationName: args.operationName?.trim() || undefined,
})
return normalizeGraphqlResponse(response)
},
},
)

function normalizeGraphqlResponse(response: {
status: number
body: { data?: unknown; errors?: Array<unknown>; extensions?: unknown } | null
}) {
if (
!response.body ||
typeof response.body !== 'object' ||
Array.isArray(response.body)
) {
return { status: response.status, data: null, errors: null, extensions: null }
}
const { data, errors, extensions } = response.body
if (errors != null && !Array.isArray(errors)) {
throw new Error('GitHub GraphQL response "errors" is not an array.')
}
return {
status: response.status,
data: data ?? null,
errors: errors ?? null,
extensions: extensions ?? null,
}
Comment on lines +76 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For GitHub's GraphQL endpoint, what JSON body is returned on authentication failures (e.g., bad token)? Does it return messagewithout a GraphQLerrors array?

💡 Result:

For GitHub's GraphQL endpoint (https://api.github.com/graphql), authentication failures (e.g., bad or missing token) return an HTTP 401 Unauthorized response with a JSON body containing a top-level message field (e.g., "This endpoint requires you to be authenticated." or "Bad credentials") and a documentation_url, but no GraphQL errors array or data field. This is a standard GitHub API error format used before GraphQL processing, causing GraphQL clients to reject it as invalid (lacking data or errors). Examples from sources: - {"message": "This endpoint requires you to be authenticated.", "documentation_url": "https://docs.github.com/v3/#authentication"} - {"message": "Bad credentials", "documentation_url": "https://docs.github.com/rest"}

Citations:


🏁 Script executed:

# First, let's locate and read the GitHub GraphQL file to understand the full context
find . -name "github-graphql.ts" -type f

Repository: kentcdodds/kody

Length of output: 102


🏁 Script executed:

# Read the file around the specified lines (76-85) to see the full context
if [ -f "./mcp/capabilities/coding/github-graphql.ts" ]; then
  echo "=== File found, reading context (lines 60-100) ==="
  cat -n ./mcp/capabilities/coding/github-graphql.ts | sed -n '60,100p'
else
  echo "File not found at expected path, searching for it..."
  git ls-files | grep -i "github-graphql"
fi

Repository: kentcdodds/kody

Length of output: 986


Non-GraphQL JSON error payloads are being dropped.

If GitHub responds with a JSON body like { "message": "Bad credentials" } (which it does on authentication failures), the normalizer returns errors: null, losing the actionable error message. GitHub's GraphQL endpoint returns HTTP 401 responses with a top-level message field when authentication fails, but no GraphQL errors array.

🛠️ Proposed fix
 	const { data, errors, extensions } = response.body
 	if (errors != null && !Array.isArray(errors)) {
 		throw new Error('GitHub GraphQL response "errors" is not an array.')
 	}
+	const fallbackMessage =
+		typeof (response.body as { message?: unknown }).message === 'string'
+			? (response.body as { message: string }).message
+			: null
 	return {
 		status: response.status,
 		data: data ?? null,
-		errors: errors ?? null,
+		errors: errors ?? (fallbackMessage ? [{ message: fallbackMessage }] : null),
 		extensions: extensions ?? null,
 	}
📝 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
const { data, errors, extensions } = response.body
if (errors != null && !Array.isArray(errors)) {
throw new Error('GitHub GraphQL response "errors" is not an array.')
}
return {
status: response.status,
data: data ?? null,
errors: errors ?? null,
extensions: extensions ?? null,
}
const { data, errors, extensions } = response.body
if (errors != null && !Array.isArray(errors)) {
throw new Error('GitHub GraphQL response "errors" is not an array.')
}
const fallbackMessage =
typeof (response.body as { message?: unknown }).message === 'string'
? (response.body as { message: string }).message
: null
return {
status: response.status,
data: data ?? null,
errors: errors ?? (fallbackMessage ? [{ message: fallbackMessage }] : null),
extensions: extensions ?? null,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mcp/capabilities/coding/github-graphql.ts` around lines 76 - 85, The
normalizer currently drops non-GraphQL JSON error payloads by returning errors:
null when response.body contains a top-level message (e.g., { "message": "Bad
credentials" }); update the logic around the destructured response.body (data,
errors, extensions) to detect when errors is null/undefined and
response.body.message is present and, in that case, populate errors with a
single-element array like [{ message: response.body.message }] (or otherwise
surface the message in the returned object), while preserving status, data (null
if absent), and extensions; modify the block that throws on non-array errors to
still handle non-GraphQL payloads and return the constructed errors array
instead of dropping the message.

}
Loading
Loading