Repository navigation
Add GitHub GraphQL capability and docs #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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.') | ||
| } | ||
| } |
| 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', | ||
| }) | ||
| } | ||
|
|
||
| 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) | ||
| }, | ||
| }, | ||
| ) | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 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
Apply 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 |
||||||||||||||||||||||||||||||||||||||||||||||||||
| .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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 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 fRepository: 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"
fiRepository: kentcdodds/kody Length of output: 986 Non-GraphQL JSON error payloads are being dropped. If GitHub responds with a JSON body like 🛠️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.