-
Notifications
You must be signed in to change notification settings - Fork 226
fix: surface source description in MCP tool description (#267) #309
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,74 @@ | ||
| import { describe, it, expect, beforeAll, afterAll } from "vitest"; | ||
| import type { ConnectorManager } from "../../connectors/manager.js"; | ||
| import { setupManagerWithFixture, FIXTURES, loadFixtureConfig } from "../../__fixtures__/helpers.js"; | ||
| import { getExecuteSqlMetadata, getSearchObjectsMetadata } from "../tool-metadata.js"; | ||
| import { initializeToolRegistry } from "../../tools/registry.js"; | ||
|
|
||
| // Import SQLite connector to ensure it's registered | ||
| import "../../connectors/sqlite/index.js"; | ||
|
|
||
| describe("tool-metadata description propagation", () => { | ||
| let manager: ConnectorManager; | ||
|
|
||
| beforeAll(async () => { | ||
| // readonly-maxrows fixture has three sources: | ||
| // readonly_limited: description = "Read-only database for safe queries", readonly + max_rows | ||
| // writable_limited: no description, writable + max_rows | ||
| // writable_unlimited: no description, writable + no limits | ||
| manager = await setupManagerWithFixture(FIXTURES.READONLY_MAXROWS); | ||
| const { sources, tools } = loadFixtureConfig(FIXTURES.READONLY_MAXROWS); | ||
| initializeToolRegistry({ sources, tools: tools || [] }); | ||
| }, 30000); | ||
|
|
||
| afterAll(async () => { | ||
| if (manager) { | ||
| await manager.disconnect(); | ||
| } | ||
| }); | ||
|
|
||
| describe("getExecuteSqlMetadata", () => { | ||
| it("prepends user description from source config when present", () => { | ||
| const metadata = getExecuteSqlMetadata("readonly_limited"); | ||
|
|
||
| expect(metadata.description).toContain("Read-only database for safe queries"); | ||
| // Original technical context must still be preserved | ||
| expect(metadata.description).toContain("readonly_limited"); | ||
| expect(metadata.description).toContain("sqlite"); | ||
| expect(metadata.description).toContain("[READ-ONLY MODE]"); | ||
| // User description comes first, technical template follows | ||
| expect(metadata.description.indexOf("Read-only database for safe queries")).toBeLessThan( | ||
| metadata.description.indexOf("Execute SQL queries") | ||
| ); | ||
| }); | ||
|
|
||
| it("omits the prefix entirely when source has no description", () => { | ||
| const metadata = getExecuteSqlMetadata("writable_limited"); | ||
|
|
||
| // No extra prefix — description starts with the template | ||
| expect(metadata.description.startsWith("Execute SQL queries")).toBe(true); | ||
| expect(metadata.description).toContain("writable_limited"); | ||
| expect(metadata.description).toContain("sqlite"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("getSearchObjectsMetadata", () => { | ||
| it("prepends user description from source config when present", () => { | ||
| const metadata = getSearchObjectsMetadata("readonly_limited"); | ||
|
|
||
| expect(metadata.description).toContain("Read-only database for safe queries"); | ||
| expect(metadata.description).toContain("readonly_limited"); | ||
| expect(metadata.description).toContain("sqlite"); | ||
| expect(metadata.description.indexOf("Read-only database for safe queries")).toBeLessThan( | ||
| metadata.description.indexOf("Search and list database objects") | ||
| ); | ||
| }); | ||
|
|
||
| it("omits the prefix entirely when source has no description", () => { | ||
| const metadata = getSearchObjectsMetadata("writable_unlimited"); | ||
|
|
||
| expect(metadata.description.startsWith("Search and list database objects")).toBe(true); | ||
| expect(metadata.description).toContain("writable_unlimited"); | ||
| expect(metadata.description).toContain("sqlite"); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { buildSourceDescriptionPrefix } from "../tool-metadata.js"; | ||
|
|
||
| describe("buildSourceDescriptionPrefix", () => { | ||
| it("returns empty string when description is undefined", () => { | ||
| expect(buildSourceDescriptionPrefix(undefined)).toBe(""); | ||
| }); | ||
|
|
||
| it("returns empty string when description is empty", () => { | ||
| expect(buildSourceDescriptionPrefix("")).toBe(""); | ||
| }); | ||
|
|
||
| it("returns empty string when description is whitespace-only", () => { | ||
| expect(buildSourceDescriptionPrefix(" ")).toBe(""); | ||
| expect(buildSourceDescriptionPrefix("\t\n")).toBe(""); | ||
| }); | ||
|
|
||
| it("appends '. ' when description has no sentence-ending punctuation", () => { | ||
| expect(buildSourceDescriptionPrefix("Prod DB")).toBe("Prod DB. "); | ||
| }); | ||
|
|
||
| it("appends only a space when description already ends with a period", () => { | ||
| // Guards against the classic "Prod DB.. Execute SQL..." double-period bug. | ||
| expect(buildSourceDescriptionPrefix("Prod DB.")).toBe("Prod DB. "); | ||
| }); | ||
|
|
||
| it("appends only a space when description ends with '!'", () => { | ||
| expect(buildSourceDescriptionPrefix("Production DB!")).toBe("Production DB! "); | ||
| }); | ||
|
|
||
| it("appends only a space when description ends with '?'", () => { | ||
| expect(buildSourceDescriptionPrefix("Query me?")).toBe("Query me? "); | ||
| }); | ||
|
|
||
| it("appends only a space when description ends with ':'", () => { | ||
| // A trailing colon naturally introduces what follows (the tool template), | ||
| // so adding '.' here would produce the artifact "Details below:. Execute..." | ||
| expect(buildSourceDescriptionPrefix("Details below:")).toBe("Details below: "); | ||
| }); | ||
|
|
||
| it("trims surrounding whitespace before assessing punctuation", () => { | ||
| expect(buildSourceDescriptionPrefix(" Prod DB ")).toBe("Prod DB. "); | ||
| expect(buildSourceDescriptionPrefix(" Prod DB. ")).toBe("Prod DB. "); | ||
| }); | ||
|
|
||
| it("preserves internal whitespace and punctuation", () => { | ||
| // Internal formatting (newlines, multiple words, mid-sentence punctuation) | ||
| // is the user's intent and must not be altered. | ||
| expect(buildSourceDescriptionPrefix("Line 1\nLine 2")).toBe("Line 1\nLine 2. "); | ||
| expect(buildSourceDescriptionPrefix("Line A, Line B")).toBe("Line A, Line B. "); | ||
| }); | ||
|
|
||
| it("does not treat non-sentence-ending punctuation as terminators", () => { | ||
| // ')' and ';' are mid-sentence / structural punctuation; the helper | ||
| // should still append ". " to produce a complete sentence boundary. | ||
| // (':' is handled separately — see the colon-specific test above.) | ||
| expect(buildSourceDescriptionPrefix("(read-only)")).toBe("(read-only). "); | ||
| expect(buildSourceDescriptionPrefix("Clause 1; clause 2")).toBe("Clause 1; clause 2. "); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,33 @@ export interface ToolMetadata { | |
| annotations: ToolAnnotations; | ||
| } | ||
|
|
||
| /** | ||
| * Build a prefix string for prepending a source's user-provided `description` | ||
| * onto a generated tool description. Returns "" when no description is set | ||
| * (undefined, empty, or whitespace-only). Normalizes surrounding whitespace | ||
| * with `trim()` and skips adding a period when the description already ends | ||
| * with one of "." / "!" / "?" / ":" — the colon is included because a | ||
| * trailing colon naturally introduces what follows (the generic tool | ||
| * template) and appending "." after it would produce artifacts like | ||
| * "Details below:. Execute SQL...". | ||
| * | ||
| * Examples: | ||
| * undefined -> "" | ||
| * " " -> "" | ||
| * "Prod DB" -> "Prod DB. " | ||
| * "Prod DB." -> "Prod DB. " (no double period) | ||
| * " Prod DB! " -> "Prod DB! " (trimmed, no added period) | ||
| * "Query me?" -> "Query me? " | ||
| * "Details below:" -> "Details below: " (colon introduces what follows) | ||
| * "Clause 1; clause 2" -> "Clause 1; clause 2. " (semicolon is mid-sentence) | ||
| * "(read-only)" -> "(read-only). " (closing paren is mid-sentence) | ||
| */ | ||
| export function buildSourceDescriptionPrefix(description: string | undefined): string { | ||
| const trimmed = description?.trim() ?? ""; | ||
| if (!trimmed) return ""; | ||
| return /[.!?:]$/.test(trimmed) ? `${trimmed} ` : `${trimmed}. `; | ||
| } | ||
|
Comment on lines
+63
to
+67
|
||
|
|
||
| /** | ||
| * Convert a Zod schema object to simplified parameter list | ||
| * @param schema - Zod schema object (e.g., { sql: z.string().describe("...") }) | ||
|
|
@@ -106,12 +133,15 @@ export function getExecuteSqlMetadata(sourceId: string): ToolMetadata { | |
| ? `Execute SQL (${dbType})` | ||
| : `Execute SQL on ${sourceId} (${dbType})`; | ||
|
|
||
| // Determine description with more context | ||
| // Determine description with more context. | ||
| // Prepend the user-provided `description` from the source config (if set) | ||
| // so AI clients reading the MCP tool list see the source's purpose first. | ||
| const userDescPrefix = buildSourceDescriptionPrefix(sourceConfig.description); | ||
| const readonlyNote = executeOptions.readonly ? " [READ-ONLY MODE]" : ""; | ||
| const maxRowsNote = executeOptions.maxRows ? ` (limited to ${executeOptions.maxRows} rows)` : ""; | ||
| const description = isSingleSource | ||
| ? `Execute SQL queries on the ${dbType} database${readonlyNote}${maxRowsNote}` | ||
| : `Execute SQL queries on the '${sourceId}' ${dbType} database${readonlyNote}${maxRowsNote}`; | ||
| ? `${userDescPrefix}Execute SQL queries on the ${dbType} database${readonlyNote}${maxRowsNote}` | ||
| : `${userDescPrefix}Execute SQL queries on the '${sourceId}' ${dbType} database${readonlyNote}${maxRowsNote}`; | ||
|
|
||
| // Build annotations object with all standard MCP hints | ||
| const isReadonly = executeOptions.readonly === true; | ||
|
|
@@ -149,9 +179,12 @@ export function getSearchObjectsMetadata(sourceId: string): { name: string; desc | |
| const title = isSingleSource | ||
| ? `Search Database Objects (${dbType})` | ||
| : `Search Database Objects on ${sourceId} (${dbType})`; | ||
| // Prepend the user-provided `description` from the source config (if set) | ||
| // so AI clients reading the MCP tool list see the source's purpose first. | ||
| const userDescPrefix = buildSourceDescriptionPrefix(sourceConfig.description); | ||
| const description = isSingleSource | ||
| ? `Search and list database objects (schemas, tables, columns, procedures, functions, indexes) on the ${dbType} database` | ||
| : `Search and list database objects (schemas, tables, columns, procedures, functions, indexes) on the '${sourceId}' ${dbType} database`; | ||
| ? `${userDescPrefix}Search and list database objects (schemas, tables, columns, procedures, functions, indexes) on the ${dbType} database` | ||
| : `${userDescPrefix}Search and list database objects (schemas, tables, columns, procedures, functions, indexes) on the '${sourceId}' ${dbType} database`; | ||
|
|
||
| return { | ||
| name: toolName, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test locks in the current
buildSourceDescriptionPrefix("Details below:") === "Details below:. "behavior, which would surface as"Details below:. Execute …"in tool descriptions. If:should act as a natural separator, the helper should return"Details below: "(space only) instead, and this assertion should be updated accordingly.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test updated in 8d63191. The
"Details below:"case now asserts"Details below: "(space-only) and lives in a dedicated test alongside the other sentence-ending cases. The remaining/[.!?:]$/non-matches ()and;) stay in the 'non-terminators' test with a comment explaining why:was moved out.