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 .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"metadata": {
"description": "The code context layer for AI coding agents",
"version": "0.6.3"
"version": "0.6.4"
},
"plugins": [
{
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "githits",
"version": "0.6.3",
"version": "0.6.4",
"description": "The code context layer for AI coding agents",
"author": {
"name": "GitHits"
Expand Down
2 changes: 1 addition & 1 deletion .plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "githits",
"version": "0.6.3",
"version": "0.6.4",
"description": "The code context layer for AI coding agents",
"author": {
"name": "GitHits"
Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 64 additions & 0 deletions docs/implementation/mcp-tool-annotations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# MCP tool annotations

OpenAI marketplace validation requires every MCP tool to set
`readOnlyHint`, `openWorldHint`, and `destructiveHint` explicitly. The
annotations are owned by the public `@githits/mcp` package because that package
defines and registers the shared tool descriptors used by both the local CLI
server and the remote MCP server.

The standard MCP annotation object has boolean hints but no field for reviewer
justifications. Keep the submission rationale below aligned with the descriptor
values and observable tool behavior.

OpenAI's current guidance says `readOnlyHint` must be false when a tool can
create service-side state or start background work. `openWorldHint` and
`destructiveHint` describe the impact of writes and are not relevant when
`readOnlyHint` is true. GitHits state-changing tools have bounded, additive
service-side effects; none can publish, message external recipients, modify
external systems, delete user data, overwrite user data, or perform an
irreversible public action.

| Tool | `readOnlyHint` | `openWorldHint` | `destructiveHint` | Justification |
| --- | --- | --- | --- | --- |
| `get_example` | `false` | `false` | `false` | Generates a new result and may create service-side state associated with that result, so it is not strictly read-only. The effect is additive and cannot publish, modify external systems, or delete/overwrite user data. |
| `search_language` | `true` | `false` | `false` | Looks up supported language names and aliases and returns matches without creating or modifying application state. The other two hints are explicitly false because the tool performs no write. |
| `feedback` | `false` | `false` | `false` | Submits feedback and therefore creates an additive service-side entry. It cannot publish, modify external systems, delete/overwrite user data, or perform a destructive action. |
| `search` | `false` | `false` | `false` | Searches public package/repository code and docs, and may start background preparation when requested content is not immediately available. That work cannot change source repositories or user data and is non-destructive. |
| `search_status` | `true` | `false` | `false` | Polls an existing search reference and retrieves progress/results; it does not start or modify the underlying work. The other two hints are explicitly false because the tool performs no write. |
| `code_files` | `false` | `false` | `false` | Lists files for a public target and may start background preparation when the requested content is not immediately available. It cannot modify the upstream package/repository or destructively change user data. |
| `code_read` | `false` | `false` | `false` | Reads file content for a public target and may start background preparation when the requested content is not immediately available. It cannot modify the source repository, publish content, or delete/overwrite user data. |
| `code_grep` | `false` | `false` | `false` | Searches source for a public target and may start background preparation when the requested content is not immediately available. Any service-side effect is bounded and non-destructive. |
| `docs_list` | `false` | `false` | `false` | Lists public package documentation and may start background preparation when the requested documentation is not immediately available. It cannot change public sources or destructively alter user data. |
| `docs_read` | `true` | `false` | `false` | Retrieves an available documentation page identified by `page_id` and optionally slices its line range. It does not initiate content preparation or modify state; the other two hints are explicitly false because there is no write. |
| `pkg_info` | `true` | `false` | `false` | Retrieves and computes package identity, release, repository, security-summary, and changelog-summary facts. It exposes no state-changing action; the other two hints are explicitly false because there is no write. |
| `pkg_vulns` | `true` | `false` | `false` | Retrieves and filters vulnerability/advisory facts for a package version without changing packages, advisories, or user state. The other two hints are explicitly false because there is no write. |
| `pkg_deps` | `true` | `false` | `false` | Retrieves and computes direct/transitive dependency information and optional issue analysis. It does not install, upgrade, or modify dependencies; the other two hints are explicitly false because there is no write. |
| `pkg_changelog` | `true` | `false` | `false` | Retrieves and filters changelog/release-note information from package or repository sources. It cannot create releases or modify upstream content; the other two hints are explicitly false because there is no write. |
| `pkg_upgrade_review` | `true` | `false` | `false` | Computes an evidence-only comparison between current and target versions using package, vulnerability, dependency, and changelog data. It does not install, update, publish, or otherwise modify a dependency; the other two hints are explicitly false because there is no write. |

The descriptor type requires all three booleans, and the server-level regression
test enumerates the full public tool surface so a future tool cannot silently
omit marketplace-required annotations.

## Output schemas and token efficiency

The MCP tools intentionally do not declare `outputSchema` yet. The current
handlers return unstructured `TextContent` and do not return
`structuredContent`. MCP defines `outputSchema` as optional, and requires a
successful `structuredContent` value to conform whenever one is declared.
OpenAI likewise recommends an output schema for tools that return structured
content.

Mirroring the existing JSON envelopes into `structuredContent` would make the
default compact response carry both `text-v1` and the full structured payload.
That would increase model context use, especially for code reads, grep matches,
documentation, changelog bodies, and dependency graphs. A published user trace
already showed the practical cost of large structured responses: the agent
explicitly selected JSON in 86 of 91 GitHits calls.

For that reason, every format-selectable MCP tool now advertises `text-v1` as
both the first enum value and the explicit schema default. JSON remains an
opt-in compatibility mode. If structured output is added later, it should use a
small per-tool control-plane contract (status, IDs, cursors, counts,
truncation, and next actions) rather than duplicate code, docs, examples, or
other large result bodies.
2 changes: 1 addition & 1 deletion gemini-extension.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "githits",
"version": "0.6.3",
"version": "0.6.4",
"description": "The code context layer for AI coding agents",
"mcpServers": {
"githits": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "githits",
"description": "The code context layer for AI coding agents",
"version": "0.6.3",
"version": "0.6.4",
"mcpName": "com.githits/githits",
"type": "module",
"workspaces": [
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@githits/mcp",
"version": "0.6.2",
"version": "0.6.3",
"description": "Reusable MCP server APIs and tools for GitHits",
"type": "module",
"files": [
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/mcp/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Routing: use \`get_example\` for canonical cross-project examples; use \`search\

When presenting \`get_example\` output, include source repository provenance/citations from GitHits' generated references/provenance section whenever present.`;

const PACKAGE_TOOLS_PREAMBLE = `Indexed package/source tools inspect third-party dependency source, docs, and registry metadata. Package targets use \`registry:name[@version]\`; repo targets use GitHub URLs. Default outputs are compact \`text-v1\`; pass \`format:"json"\` only for structured parsing.`;
const PACKAGE_TOOLS_PREAMBLE = `Indexed package/source tools inspect third-party dependency source, docs, and registry metadata. Package targets use \`registry:name[@version]\`; repo targets use GitHub URLs. Prefer the default compact \`text-v1\` output; request JSON only when exact structured fields are necessary.`;

const SUPPORTING_SKILL_TIP =
"For clients that support Agent Skills, install the `githits-mcp` skill and add a short agent-instructions pointer so GitHits stays the default OSS context layer even when clients ignore server-level MCP instructions.";
Expand Down
64 changes: 64 additions & 0 deletions packages/mcp/src/mcp/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "bun:test";
import { z } from "zod";
import { getMcpToolDescriptors } from "./server.js";

const BOUNDED_NON_DESTRUCTIVE_WRITES = new Set([
"get_example",
"feedback",
"search",
"code_files",
"code_read",
"code_grep",
"docs_list",
]);

const FORMAT_SELECTABLE_TOOLS = new Set([
"get_example",
"search_language",
"search",
"search_status",
"code_files",
"code_read",
"code_grep",
"docs_list",
"docs_read",
"pkg_info",
"pkg_vulns",
"pkg_deps",
"pkg_changelog",
"pkg_upgrade_review",
]);

describe("MCP tool annotations", () => {
it("explicitly classifies the potential impact of every public tool", () => {
const descriptors = getMcpToolDescriptors();

expect(descriptors).toHaveLength(15);

for (const descriptor of descriptors) {
expect(descriptor.annotations, descriptor.name).toEqual({
readOnlyHint: !BOUNDED_NON_DESTRUCTIVE_WRITES.has(descriptor.name),
openWorldHint: false,
destructiveHint: false,
});
}
});
});

describe("MCP output format", () => {
it("advertises token-efficient text-v1 as the explicit default", () => {
const descriptors = getMcpToolDescriptors();

for (const descriptor of descriptors) {
if (!FORMAT_SELECTABLE_TOOLS.has(descriptor.name)) continue;

const inputSchema = z.toJSONSchema(z.object(descriptor.schema));
const formatSchema = inputSchema.properties?.format;

expect(formatSchema, descriptor.name).toMatchObject({
default: "text-v1",
enum: ["text-v1", "text", "json"],
});
}
});
});
4 changes: 2 additions & 2 deletions packages/mcp/src/mcp/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
import {
type CompleteToolAnnotations,
createFeedbackTool,
createGetExampleTool,
createGrepRepoTool,
Expand Down Expand Up @@ -64,7 +64,7 @@ export interface McpToolDescriptor<TSchema extends ZodRawShape = ZodRawShape> {
name: string;
description: string;
schema: TSchema;
annotations?: ToolAnnotations;
annotations: CompleteToolAnnotations;
}

type ToolFactory = (services: McpToolServices) => ToolDefinition<unknown>;
Expand Down
8 changes: 7 additions & 1 deletion packages/mcp/src/tools/feedback.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { GitHitsService } from "@githits/core-internal";
import { z } from "zod";
import { withErrorHandling } from "./shared.js";
import { type ToolDefinition, textResult, type ZodRawShape } from "./types.js";
import {
BOUNDED_WRITE_TOOL_ANNOTATIONS,
type ToolDefinition,
textResult,
type ZodRawShape,
} from "./types.js";

interface FeedbackArgs {
solution_id?: string;
Expand Down Expand Up @@ -53,6 +58,7 @@ export function createFeedbackTool(
name: "feedback",
description: DESCRIPTION,
schema,
annotations: BOUNDED_WRITE_TOOL_ANNOTATIONS,
handler: async (args) => {
return withErrorHandling("submit feedback", async () => {
const result = await service.submitFeedback({
Expand Down
12 changes: 9 additions & 3 deletions packages/mcp/src/tools/get-example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { z } from "zod";
import { extractSolutionId } from "../shared/extract-solution-id.js";
import { GET_EXAMPLE_GUARDRAIL } from "./guardrails.js";
import { withErrorHandling } from "./shared.js";
import { type ToolDefinition, textResult, type ZodRawShape } from "./types.js";
import {
BOUNDED_WRITE_TOOL_ANNOTATIONS,
type ToolDefinition,
textResult,
type ZodRawShape,
} from "./types.js";

interface GetExampleArgs {
query: string;
Expand Down Expand Up @@ -31,8 +36,8 @@ const schema: ZodRawShape = {
.optional()
.describe("License filtering mode: strict (default), yolo, or custom."),
format: z
.enum(["json", "text", "text-v1"])
.optional()
.enum(["text-v1", "text", "json"])
.default("text-v1")
.describe(
'Response format. Default `text-v1` returns markdown directly with source repository provenance when available and a trailing `solution_id` line when available. Pass `format: "json"` for `{result, solution_id?}`.',
),
Expand All @@ -51,6 +56,7 @@ export function createGetExampleTool(
name: "get_example",
description: DESCRIPTION,
schema,
annotations: BOUNDED_WRITE_TOOL_ANNOTATIONS,
handler: async (args) => {
return withErrorHandling("get example", async () => {
const markdown = await service.search({
Expand Down
6 changes: 5 additions & 1 deletion packages/mcp/src/tools/grep-repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ describe("createGrepRepoTool — metadata", () => {
"wait_timeout_ms",
]);
expect(tool.schema.symbol_fields?.description).toContain("parent_path");
expect(tool.annotations?.readOnlyHint).toBe(true);
expect(tool.annotations).toEqual({
readOnlyHint: false,
openWorldHint: false,
destructiveHint: false,
});
});
});

Expand Down
13 changes: 9 additions & 4 deletions packages/mcp/src/tools/grep-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import {
} from "./code-navigation-shared.js";
import { CODE_GREP_GUARDRAIL } from "./guardrails.js";
import { mcpMappedErrorResult } from "./shared.js";
import { type ToolDefinition, textResult, type ZodRawShape } from "./types.js";
import {
BOUNDED_WRITE_TOOL_ANNOTATIONS,
type ToolDefinition,
textResult,
type ZodRawShape,
} from "./types.js";

export interface GrepRepoArgs {
target: CodeTargetArg;
Expand Down Expand Up @@ -88,8 +93,8 @@ const schema: ZodRawShape = {
"Max milliseconds to wait for indexing (0-60000, default 20000). On an `INDEXING` error envelope, use `details.indexingEstimate` when present to decide whether to wait longer, or pass an already-indexed version/ref from `details.availableVersions` / `details.availableRefs`; `suggestedRefs` are fuzzy hints and may need indexing first.",
),
format: z
.enum(["json", "text", "text-v1"])
.optional()
.enum(["text-v1", "text", "json"])
.default("text-v1")
.describe(
'Response format. Default `text-v1` — compact line-oriented output (matches grouped by file with grep -A/-B notation for context). Pass `format: "json"` for the structured envelope. `text` is an alias for `text-v1`. Errors stay JSON-formatted in either mode for now.',
),
Expand All @@ -112,7 +117,7 @@ export function createGrepRepoTool(
name: "code_grep",
description: DESCRIPTION,
schema,
annotations: { readOnlyHint: true },
annotations: BOUNDED_WRITE_TOOL_ANNOTATIONS,
handler: async (args) => {
const target = resolveCodeTarget(args.target);
if ("content" in target) return target;
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { createSearchTool } from "./search.js";
export { createSearchLanguageTool } from "./search-language.js";
export { createSearchStatusTool } from "./search-status.js";
export type {
CompleteToolAnnotations,
ToolDefinition,
ToolHandler,
ToolResult,
Expand Down
6 changes: 5 additions & 1 deletion packages/mcp/src/tools/list-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ describe("createListFilesTool — metadata", () => {
"target",
"wait_timeout_ms",
]);
expect(tool.annotations?.readOnlyHint).toBe(true);
expect(tool.annotations).toEqual({
readOnlyHint: false,
openWorldHint: false,
destructiveHint: false,
});
});
});

Expand Down
13 changes: 9 additions & 4 deletions packages/mcp/src/tools/list-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import {
resolveCodeTarget,
} from "./code-navigation-shared.js";
import { mcpMappedErrorResult } from "./shared.js";
import { type ToolDefinition, textResult, type ZodRawShape } from "./types.js";
import {
BOUNDED_WRITE_TOOL_ANNOTATIONS,
type ToolDefinition,
textResult,
type ZodRawShape,
} from "./types.js";

export interface ListFilesArgs {
target: CodeTargetArg;
Expand Down Expand Up @@ -101,8 +106,8 @@ const schema: ZodRawShape = {
"Max milliseconds to wait for indexing (0-60000, default 20000). On an `INDEXING` error envelope, use `details.indexingEstimate` when present to decide whether to wait longer, or pass an already-indexed version/ref from `details.availableVersions` / `details.availableRefs`; `suggestedRefs` are fuzzy hints and may need indexing first.",
),
format: z
.enum(["json", "text", "text-v1"])
.optional()
.enum(["text-v1", "text", "json"])
.default("text-v1")
.describe(
'Response format. Default `text-v1` — compact paths-only listing. Pass `format: "json"` for the structured envelope. `text` is an alias for `text-v1`. Errors stay JSON-formatted in either mode for now.',
),
Expand Down Expand Up @@ -136,7 +141,7 @@ export function createListFilesTool(
name: "code_files",
description: DESCRIPTION,
schema,
annotations: { readOnlyHint: true },
annotations: BOUNDED_WRITE_TOOL_ANNOTATIONS,
handler: async (args) => {
const target = resolveCodeTarget(args.target);
if ("content" in target) return target;
Expand Down
6 changes: 5 additions & 1 deletion packages/mcp/src/tools/list-package-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ describe("createListPackageDocsTool", () => {
createMockPackageIntelligenceService(),
);
expect(tool.name).toBe("docs_list");
expect(tool.annotations?.readOnlyHint).toBe(true);
expect(tool.annotations).toEqual({
readOnlyHint: false,
openWorldHint: false,
destructiveHint: false,
});
expect(Object.keys(tool.schema)).toEqual([
"registry",
"package_name",
Expand Down
Loading