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
74 changes: 74 additions & 0 deletions src/utils/__tests__/tool-metadata.integration.test.ts
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");
});
});
});
60 changes: 60 additions & 0 deletions src/utils/__tests__/tool-metadata.test.ts
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). ");
Comment on lines +53 to +57
Copy link

Copilot AI Apr 21, 2026

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

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.

expect(buildSourceDescriptionPrefix("Clause 1; clause 2")).toBe("Clause 1; clause 2. ");
});
});
43 changes: 38 additions & 5 deletions src/utils/tool-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

buildSourceDescriptionPrefix currently appends ". " for any description not ending in ., !, or ?. For descriptions that end with a colon (e.g. "Details below:"), this produces ":." in the final tool description ("Details below:. Execute …"), which reads like a punctuation error. Consider treating : (and potentially other trailing separators) as already-terminating punctuation and only appending a space (or using a separator that never yields :.).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8d63191. Agreed — ":." is an artifact with no redeeming semantic reason. Extended the terminator regex to /[.!?:]$/ so a trailing colon is treated as already-introducing what follows, yielding "Details below: Execute SQL..." instead. ; and ) remain non-terminators — they signal mid-sentence continuation (joining clauses / closing a parenthetical within the same sentence) so . still fires for those.


/**
* Convert a Zod schema object to simplified parameter list
* @param schema - Zod schema object (e.g., { sql: z.string().describe("...") })
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading