feat: 打通出库作业链路

This commit is contained in:
zgw
2026-06-24 09:13:18 +08:00
parent a3c023a8d3
commit 1af5b3cbd3
141 changed files with 17337 additions and 1065 deletions

View File

@@ -0,0 +1,95 @@
export interface ServerConfig {
serverName: string;
defaultDatabase: string;
allowedDatabases: string[];
user?: string;
password?: string;
readOnly: boolean;
enableDdl: boolean;
encrypt: boolean;
trustServerCertificate: boolean;
connectionTimeoutMs: number;
requestTimeoutMs: number;
maxRows: number;
maxWriteRows: number;
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
const serverName = requireEnv(env, "SERVER_NAME");
const databases = parseList(env.DATABASES);
const defaultDatabase = (env.DATABASE_NAME?.trim() || databases[0] || "").trim();
if (!defaultDatabase) {
throw new Error("DATABASE_NAME or DATABASES must be configured.");
}
const allowedDatabases = mergeUnique([defaultDatabase, ...databases]);
return {
serverName,
defaultDatabase,
allowedDatabases,
user: optional(env.DB_USER),
password: optional(env.DB_PASSWORD),
readOnly: parseBoolean(env.READONLY, false),
enableDdl: parseBoolean(env.ENABLE_DDL, false),
encrypt: parseBoolean(env.DB_ENCRYPT ?? env.ENCRYPT, false),
trustServerCertificate: parseBoolean(env.TRUST_SERVER_CERTIFICATE, false),
connectionTimeoutMs: parsePositiveInt(env.CONNECTION_TIMEOUT, 30) * 1000,
requestTimeoutMs: parsePositiveInt(env.QUERY_TIMEOUT_MS, 30000),
maxRows: parsePositiveInt(env.MAX_ROWS, 1000),
maxWriteRows: parsePositiveInt(env.MAX_WRITE_ROWS, 100)
};
}
function requireEnv(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name]?.trim();
if (!value) {
throw new Error(`${name} must be configured.`);
}
return value;
}
function optional(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function parseList(value: string | undefined): string[] {
return (value ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value === undefined || value.trim() === "") {
return fallback;
}
return ["1", "true", "yes", "y", "on"].includes(value.trim().toLowerCase());
}
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (value === undefined || value.trim() === "") {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Expected a positive integer but received '${value}'.`);
}
return parsed;
}
function mergeUnique(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const key = value.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
result.push(value);
}
}
return result;
}

View File

@@ -0,0 +1,104 @@
import sql, { type config as SqlConfig, type ConnectionPool, type IResult, type Request } from "mssql";
import type { ServerConfig } from "./config.js";
import { quoteIdentifier, resolveDatabaseName, type SqlParam } from "./sql.js";
export type QueryResult = IResult<Record<string, unknown>>;
export class SqlServerClient {
private readonly pools = new Map<string, Promise<ConnectionPool>>();
constructor(private readonly config: ServerConfig) {}
get allowedDatabases(): string[] {
return this.config.allowedDatabases;
}
get defaultDatabase(): string {
return this.config.defaultDatabase;
}
get readOnly(): boolean {
return this.config.readOnly;
}
get enableDdl(): boolean {
return this.config.enableDdl;
}
get maxRows(): number {
return this.config.maxRows;
}
get maxWriteRows(): number {
return this.config.maxWriteRows;
}
async query(
databaseName: string | undefined,
statement: string,
params: SqlParam[] = []
): Promise<QueryResult> {
const pool = await this.getPool(databaseName);
const request = pool.request();
bindParams(request, params);
return request.query(statement) as Promise<QueryResult>;
}
async batch(databaseName: string | undefined, statement: string): Promise<QueryResult> {
const pool = await this.getPool(databaseName);
return pool.request().batch(statement) as Promise<QueryResult>;
}
async close(): Promise<void> {
const pools = await Promise.all([...this.pools.values()]);
await Promise.all(pools.map((pool) => pool.close()));
this.pools.clear();
}
private async getPool(requestedDatabase: string | undefined): Promise<ConnectionPool> {
const database = resolveDatabaseName(
requestedDatabase,
this.config.defaultDatabase,
this.config.allowedDatabases
);
const key = database.toLowerCase();
const existing = this.pools.get(key);
if (existing) {
return existing;
}
const poolPromise = new sql.ConnectionPool(buildSqlConfig(this.config, database)).connect();
this.pools.set(key, poolPromise);
return poolPromise;
}
}
export function useDatabaseStatement(databaseName: string): string {
return `USE ${quoteIdentifier(databaseName)};`;
}
function buildSqlConfig(config: ServerConfig, database: string): SqlConfig {
return {
server: config.serverName,
database,
user: config.user,
password: config.password,
connectionTimeout: config.connectionTimeoutMs,
requestTimeout: config.requestTimeoutMs,
options: {
encrypt: config.encrypt,
trustServerCertificate: config.trustServerCertificate
},
pool: {
max: 5,
min: 0,
idleTimeoutMillis: 30000
}
};
}
function bindParams(request: Request, params: SqlParam[]): void {
for (const param of params) {
request.input(param.name, param.value);
}
}

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { loadConfig } from "./config.js";
import { SqlServerClient } from "./db.js";
import { registerSqlServerTools } from "./tools.js";
const VERSION = "1.0.0";
if (process.argv.includes("--help") || process.argv.includes("-h")) {
console.log(`sqlserver-mcp-server ${VERSION}
Local stdio MCP server for Microsoft SQL Server.
Required environment:
SERVER_NAME
DATABASE_NAME or DATABASES
Optional environment:
DB_USER, DB_PASSWORD, READONLY, ENABLE_DDL, TRUST_SERVER_CERTIFICATE,
DB_ENCRYPT, CONNECTION_TIMEOUT, QUERY_TIMEOUT_MS, MAX_ROWS, MAX_WRITE_ROWS`);
process.exit(0);
}
if (process.argv.includes("--version") || process.argv.includes("-v")) {
console.log(VERSION);
process.exit(0);
}
async function main(): Promise<void> {
const config = loadConfig();
const client = new SqlServerClient(config);
const server = new McpServer({
name: "sqlserver-mcp-server",
version: VERSION
});
registerSqlServerTools(server, client);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("sqlserver-mcp-server running via stdio");
const shutdown = async () => {
await client.close();
await server.close();
};
process.once("SIGINT", () => {
shutdown().finally(() => process.exit(0));
});
process.once("SIGTERM", () => {
shutdown().finally(() => process.exit(0));
});
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

View File

@@ -0,0 +1,121 @@
export interface SqlParam {
name: string;
value: unknown;
}
export interface WhereClause {
clause: string;
params: SqlParam[];
}
export function quoteIdentifier(identifier: string): string {
const trimmed = identifier.trim();
if (!trimmed) {
throw new Error("SQL identifier must not be empty.");
}
return `[${trimmed.replaceAll("]", "]]")}]`;
}
export function qualifyName(schemaName: string, objectName: string): string {
return `${quoteIdentifier(schemaName)}.${quoteIdentifier(objectName)}`;
}
export function assertSafeSelect(query: string): void {
const trimmed = query.trim();
if (!trimmed) {
throw new Error("SELECT query must not be empty.");
}
const withoutTrailingSemicolon = trimmed.replace(/;+\s*$/u, "");
if (withoutTrailingSemicolon.includes(";")) {
throw new Error("Only a single SELECT statement is allowed.");
}
if (!/^(select|with)\b/iu.test(withoutTrailingSemicolon)) {
throw new Error("Only SELECT queries or CTE queries starting with WITH are allowed.");
}
if (/\b(insert|update|delete|merge|drop|alter|create|truncate|exec|execute|grant|revoke)\b/iu.test(withoutTrailingSemicolon)) {
throw new Error("SELECT query contains a disallowed mutating keyword.");
}
}
export function buildWhereClause(criteria: Record<string, unknown>, prefix: string): WhereClause {
const entries = Object.entries(criteria).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
throw new Error("WHERE criteria must include at least one field.");
}
const params: SqlParam[] = [];
const parts = entries.map(([key, value], index) => {
const column = quoteIdentifier(key);
if (value === null) {
return `${column} IS NULL`;
}
const name = `${prefix}${index}`;
params.push({ name, value });
return `${column} = @${name}`;
});
return {
clause: parts.join(" AND "),
params
};
}
export function resolveDatabaseName(
requested: string | undefined,
defaultDatabase: string,
allowedDatabases: string[]
): string {
const databaseName = (requested?.trim() || defaultDatabase.trim());
if (!databaseName) {
throw new Error("Database name is required.");
}
if (allowedDatabases.length === 0) {
return databaseName;
}
const match = allowedDatabases.find((allowed) => allowed.toLowerCase() === databaseName.toLowerCase());
if (!match) {
throw new Error(`Database '${databaseName}' is not in the configured allowed database list.`);
}
return match;
}
export function validateProcedureDefinition(
definition: string,
schemaName: string,
procedureName: string
): void {
const trimmed = definition.trim();
if (!/^(create\s+(or\s+alter\s+)?|alter\s+)proc(edure)?\b/iu.test(trimmed)) {
throw new Error("Stored procedure definition must start with CREATE PROCEDURE, ALTER PROCEDURE, or CREATE OR ALTER PROCEDURE.");
}
const nameMatch = trimmed.match(/^(?:create\s+(?:or\s+alter\s+)?|alter\s+)proc(?:edure)?\s+([^\s(@;]+)/iu);
if (!nameMatch) {
throw new Error("Stored procedure definition must include a procedure name.");
}
const normalizedExpected = normalizeObjectName(`${schemaName}.${procedureName}`);
const normalizedActual = normalizeObjectName(nameMatch[1]);
if (normalizedActual !== normalizedExpected) {
throw new Error(`Stored procedure definition name '${nameMatch[1]}' does not match requested name '${schemaName}.${procedureName}'.`);
}
}
function normalizeObjectName(name: string): string {
return name
.replaceAll("[", "")
.replaceAll("]", "")
.replaceAll("\"", "")
.split(".")
.map((part) => part.trim().toLowerCase())
.filter(Boolean)
.join(".");
}

View File

@@ -0,0 +1,661 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { SqlServerClient } from "./db.js";
import {
assertSafeSelect,
buildWhereClause,
qualifyName,
quoteIdentifier,
validateProcedureDefinition,
type SqlParam
} from "./sql.js";
const JsonValueSchema: z.ZodType<unknown> = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(z.unknown()),
z.record(z.string(), z.unknown())
]);
const DatabaseNameSchema = z.string().min(1).optional().describe("Optional database name from the configured DATABASES allowlist.");
const SchemaNameSchema = z.string().min(1).default("dbo").describe("SQL Server schema name. Defaults to dbo.");
const ObjectNameSchema = z.string().min(1).describe("SQL Server object name without schema.");
const ResponseFormatSchema = z.enum(["markdown", "json"]).default("markdown");
export function registerSqlServerTools(server: McpServer, client: SqlServerClient): void {
server.registerTool(
"sqlserver_list_databases",
{
title: "List Configured SQL Server Databases",
description: "List database names this SQL Server MCP is allowed to access. This reads local MCP configuration only.",
inputSchema: z.object({ response_format: ResponseFormatSchema }).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true
}
},
async ({ response_format }) => toolResult({
defaultDatabase: client.defaultDatabase,
databases: client.allowedDatabases
}, response_format)
);
server.registerTool(
"sqlserver_list_tables",
{
title: "List SQL Server Tables",
description: "List user tables in a configured SQL Server database, optionally filtered by schema.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
schemaName: z.string().min(1).optional(),
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true
}
},
async ({ databaseName, schemaName, response_format }) => handleTool(async () => {
const params: SqlParam[] = [];
let where = "WHERE t.is_ms_shipped = 0";
if (schemaName) {
where += " AND s.name = @schemaName";
params.push({ name: "schemaName", value: schemaName });
}
const result = await client.query(databaseName, `
SELECT
s.name AS schema_name,
t.name AS table_name,
SUM(p.rows) AS row_count,
t.create_date,
t.modify_date
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
LEFT JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0, 1)
${where}
GROUP BY s.name, t.name, t.create_date, t.modify_date
ORDER BY s.name, t.name`, params);
return toolResult({ count: result.recordset.length, tables: result.recordset }, response_format);
})
);
server.registerTool(
"sqlserver_describe_table",
{
title: "Describe SQL Server Table",
description: "Return columns, indexes, and row count metadata for a SQL Server table.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
schemaName: SchemaNameSchema,
tableName: ObjectNameSchema,
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true
}
},
async ({ databaseName, schemaName, tableName, response_format }) => handleTool(async () => {
const columns = await client.query(databaseName, `
SELECT
c.column_id,
c.name AS column_name,
ty.name AS data_type,
c.max_length,
c.precision,
c.scale,
c.is_nullable,
dc.definition AS default_definition,
ep.value AS description
FROM sys.columns c
JOIN sys.types ty ON ty.user_type_id = c.user_type_id
LEFT JOIN sys.default_constraints dc ON dc.object_id = c.default_object_id
LEFT JOIN sys.extended_properties ep
ON ep.major_id = c.object_id
AND ep.minor_id = c.column_id
AND ep.name = 'MS_Description'
WHERE c.object_id = OBJECT_ID(@objectName)
ORDER BY c.column_id`, [{ name: "objectName", value: `${schemaName}.${tableName}` }]);
const indexes = await client.query(databaseName, `
SELECT
i.name AS index_name,
i.type_desc,
i.is_unique,
i.is_primary_key,
STRING_AGG(c.name, ', ') WITHIN GROUP (ORDER BY ic.key_ordinal) AS columns
FROM sys.indexes i
JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE i.object_id = OBJECT_ID(@objectName)
AND i.name IS NOT NULL
GROUP BY i.name, i.type_desc, i.is_unique, i.is_primary_key
ORDER BY i.is_primary_key DESC, i.name`, [{ name: "objectName", value: `${schemaName}.${tableName}` }]);
return toolResult({
table: `${schemaName}.${tableName}`,
columns: columns.recordset,
indexes: indexes.recordset
}, response_format);
})
);
server.registerTool(
"sqlserver_read_data",
{
title: "Read SQL Server Data",
description: "Execute a single SELECT or CTE query against SQL Server. Mutating statements and multiple statements are blocked.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
query: z.string().min(1).describe("A single SELECT statement or CTE query."),
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true
}
},
async ({ databaseName, query, response_format }) => handleTool(async () => {
assertSafeSelect(query);
const result = await client.query(databaseName, query);
const rows = result.recordset.slice(0, client.maxRows);
return toolResult({
count: rows.length,
truncated: result.recordset.length > rows.length,
rows
}, response_format);
})
);
server.registerTool(
"sqlserver_insert_rows",
{
title: "Insert SQL Server Rows",
description: "Insert one or more rows into a table. Disabled when READONLY=true.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
schemaName: SchemaNameSchema,
tableName: ObjectNameSchema,
rows: z.array(z.record(z.string(), JsonValueSchema)).min(1).max(100),
confirm: z.literal(true).describe("Must be true to execute the insert."),
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true
}
},
async ({ databaseName, schemaName, tableName, rows, response_format }) => handleTool(async () => {
assertWritable(client);
assertWriteLimit(client, rows.length);
const result = await insertRows(client, databaseName, schemaName, tableName, rows);
return toolResult({ rowsAffected: result.rowsAffected }, response_format);
})
);
server.registerTool(
"sqlserver_update_rows",
{
title: "Update SQL Server Rows",
description: "Update table rows using equality-based SET values and WHERE criteria. WHERE is required. Disabled when READONLY=true.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
schemaName: SchemaNameSchema,
tableName: ObjectNameSchema,
values: z.record(z.string(), JsonValueSchema).refine((value) => Object.keys(value).length > 0, "values must not be empty"),
where: z.record(z.string(), JsonValueSchema).refine((value) => Object.keys(value).length > 0, "where must not be empty"),
confirm: z.literal(true).describe("Must be true to execute the update."),
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true
}
},
async ({ databaseName, schemaName, tableName, values, where, response_format }) => handleTool(async () => {
assertWritable(client);
const setClause = buildAssignments(values, "s");
const whereClause = buildWhereClause(where, "w");
const result = await client.query(
databaseName,
`UPDATE ${qualifyName(schemaName, tableName)} SET ${setClause.clause} WHERE ${whereClause.clause}`,
[...setClause.params, ...whereClause.params]
);
assertAffectedRowsWithinLimit(client, result.rowsAffected);
return toolResult({ rowsAffected: result.rowsAffected }, response_format);
})
);
server.registerTool(
"sqlserver_delete_rows",
{
title: "Delete SQL Server Rows",
description: "Delete table rows using equality-based WHERE criteria. WHERE is required. Disabled when READONLY=true.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
schemaName: SchemaNameSchema,
tableName: ObjectNameSchema,
where: z.record(z.string(), JsonValueSchema).refine((value) => Object.keys(value).length > 0, "where must not be empty"),
confirm: z.literal(true).describe("Must be true to execute the delete."),
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true
}
},
async ({ databaseName, schemaName, tableName, where, response_format }) => handleTool(async () => {
assertWritable(client);
const whereClause = buildWhereClause(where, "w");
const result = await client.query(
databaseName,
`DELETE FROM ${qualifyName(schemaName, tableName)} WHERE ${whereClause.clause}`,
whereClause.params
);
assertAffectedRowsWithinLimit(client, result.rowsAffected);
return toolResult({ rowsAffected: result.rowsAffected }, response_format);
})
);
server.registerTool(
"sqlserver_create_table",
{
title: "Create SQL Server Table",
description: "Create a SQL Server table from validated column definitions. Requires ENABLE_DDL=true and READONLY=false.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
schemaName: SchemaNameSchema,
tableName: ObjectNameSchema,
columns: z.array(z.object({
name: z.string().min(1),
type: z.string().min(1).describe("SQL Server data type, e.g. int, bigint, nvarchar(100), decimal(18,2), datetime2."),
nullable: z.boolean().default(true),
primaryKey: z.boolean().default(false),
identity: z.boolean().default(false),
defaultValue: z.string().optional()
}).strict()).min(1),
confirm: z.literal(true),
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true
}
},
async ({ databaseName, schemaName, tableName, columns, response_format }) => handleTool(async () => {
assertDdlAllowed(client);
const definitions = columns.map((column) => {
assertSafeDataType(column.type);
const pieces = [
quoteIdentifier(column.name),
column.type,
column.identity ? "IDENTITY(1,1)" : "",
column.nullable ? "NULL" : "NOT NULL",
column.defaultValue ? `DEFAULT ${column.defaultValue}` : ""
].filter(Boolean);
return pieces.join(" ");
});
const primaryKeys = columns.filter((column) => column.primaryKey).map((column) => quoteIdentifier(column.name));
if (primaryKeys.length > 0) {
definitions.push(`CONSTRAINT ${quoteIdentifier(`PK_${tableName}`)} PRIMARY KEY (${primaryKeys.join(", ")})`);
}
await client.query(databaseName, `CREATE TABLE ${qualifyName(schemaName, tableName)} (\n ${definitions.join(",\n ")}\n)`);
return toolResult({ created: true, table: `${schemaName}.${tableName}` }, response_format);
})
);
server.registerTool(
"sqlserver_drop_table",
{
title: "Drop SQL Server Table",
description: "Drop a SQL Server table if it exists. Requires ENABLE_DDL=true, READONLY=false, and confirm=true.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
schemaName: SchemaNameSchema,
tableName: ObjectNameSchema,
confirm: z.literal(true),
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: true
}
},
async ({ databaseName, schemaName, tableName, response_format }) => handleTool(async () => {
assertDdlAllowed(client);
await client.query(databaseName, `DROP TABLE IF EXISTS ${qualifyName(schemaName, tableName)}`);
return toolResult({ dropped: true, table: `${schemaName}.${tableName}` }, response_format);
})
);
server.registerTool(
"sqlserver_list_procedures",
{
title: "List SQL Server Stored Procedures",
description: "List stored procedures in a configured SQL Server database, optionally filtered by schema.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
schemaName: z.string().min(1).optional(),
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true
}
},
async ({ databaseName, schemaName, response_format }) => handleTool(async () => {
const params: SqlParam[] = [];
let where = "WHERE p.is_ms_shipped = 0";
if (schemaName) {
where += " AND s.name = @schemaName";
params.push({ name: "schemaName", value: schemaName });
}
const result = await client.query(databaseName, `
SELECT
s.name AS schema_name,
p.name AS procedure_name,
p.create_date,
p.modify_date
FROM sys.procedures p
JOIN sys.schemas s ON s.schema_id = p.schema_id
${where}
ORDER BY s.name, p.name`, params);
return toolResult({ count: result.recordset.length, procedures: result.recordset }, response_format);
})
);
server.registerTool(
"sqlserver_describe_procedure",
{
title: "Describe SQL Server Stored Procedure",
description: "Return definition and parameter metadata for a stored procedure.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
schemaName: SchemaNameSchema,
procedureName: ObjectNameSchema,
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true
}
},
async ({ databaseName, schemaName, procedureName, response_format }) => handleTool(async () => {
const objectName = `${schemaName}.${procedureName}`;
const definition = await client.query(databaseName, `
SELECT
s.name AS schema_name,
p.name AS procedure_name,
p.create_date,
p.modify_date,
m.definition
FROM sys.procedures p
JOIN sys.schemas s ON s.schema_id = p.schema_id
LEFT JOIN sys.sql_modules m ON m.object_id = p.object_id
WHERE p.object_id = OBJECT_ID(@objectName)`, [{ name: "objectName", value: objectName }]);
const parameters = await client.query(databaseName, `
SELECT
parameter_id,
name,
TYPE_NAME(user_type_id) AS data_type,
max_length,
precision,
scale,
is_output
FROM sys.parameters
WHERE object_id = OBJECT_ID(@objectName)
ORDER BY parameter_id`, [{ name: "objectName", value: objectName }]);
return toolResult({
procedure: objectName,
metadata: definition.recordset[0] ?? null,
parameters: parameters.recordset
}, response_format);
})
);
server.registerTool(
"sqlserver_create_or_alter_procedure",
{
title: "Create Or Alter SQL Server Stored Procedure",
description: "Create or alter a stored procedure. The definition must start with CREATE PROCEDURE, ALTER PROCEDURE, or CREATE OR ALTER PROCEDURE and match schemaName/procedureName. Requires ENABLE_DDL=true and READONLY=false.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
schemaName: SchemaNameSchema,
procedureName: ObjectNameSchema,
definition: z.string().min(1),
confirm: z.literal(true),
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true
}
},
async ({ databaseName, schemaName, procedureName, definition, response_format }) => handleTool(async () => {
assertDdlAllowed(client);
validateProcedureDefinition(definition, schemaName, procedureName);
await client.batch(databaseName, definition);
return toolResult({ saved: true, procedure: `${schemaName}.${procedureName}` }, response_format);
})
);
server.registerTool(
"sqlserver_drop_procedure",
{
title: "Drop SQL Server Stored Procedure",
description: "Drop a stored procedure if it exists. Requires ENABLE_DDL=true, READONLY=false, and confirm=true.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
schemaName: SchemaNameSchema,
procedureName: ObjectNameSchema,
confirm: z.literal(true),
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: true
}
},
async ({ databaseName, schemaName, procedureName, response_format }) => handleTool(async () => {
assertDdlAllowed(client);
await client.query(databaseName, `DROP PROCEDURE IF EXISTS ${qualifyName(schemaName, procedureName)}`);
return toolResult({ dropped: true, procedure: `${schemaName}.${procedureName}` }, response_format);
})
);
server.registerTool(
"sqlserver_execute_procedure",
{
title: "Execute SQL Server Stored Procedure",
description: "Execute a stored procedure with named input parameters. Disabled when READONLY=true unless allowInReadOnly=true is explicitly set in the tool call.",
inputSchema: z.object({
databaseName: DatabaseNameSchema,
schemaName: SchemaNameSchema,
procedureName: ObjectNameSchema,
parameters: z.record(z.string(), JsonValueSchema).default({}),
allowInReadOnly: z.boolean().default(false),
confirm: z.literal(true),
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true
}
},
async ({ databaseName, schemaName, procedureName, parameters, allowInReadOnly, response_format }) => handleTool(async () => {
if (client.readOnly && !allowInReadOnly) {
throw new Error("READONLY=true blocks stored procedure execution. Set allowInReadOnly=true only for procedures known to be read-only.");
}
const params = Object.entries(parameters).map(([name, value], index) => ({
sourceName: name,
param: { name: `p${index}`, value }
}));
const assignments = params.map(({ sourceName, param }) => `@${sourceName.replace(/^@/u, "")} = @${param.name}`);
const result = await client.query(
databaseName,
`EXEC ${qualifyName(schemaName, procedureName)} ${assignments.join(", ")}`,
params.map(({ param }) => param)
);
return toolResult({
rowsAffected: result.rowsAffected,
recordset: result.recordset?.slice(0, client.maxRows) ?? []
}, response_format);
})
);
}
function toolResult(data: unknown, responseFormat: "markdown" | "json" = "markdown") {
const text = responseFormat === "json" ? JSON.stringify(data, null, 2) : toMarkdown(data);
return {
content: [{ type: "text" as const, text }]
};
}
async function handleTool(action: () => Promise<ReturnType<typeof toolResult>>) {
try {
return await action();
} catch (error) {
return {
isError: true as const,
content: [{ type: "text" as const, text: formatError(error) }]
};
}
}
function toMarkdown(data: unknown): string {
return ["```json", JSON.stringify(data, null, 2), "```"].join("\n");
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return `Error: ${error.message}`;
}
return `Error: ${String(error)}`;
}
function assertWritable(client: SqlServerClient): void {
if (client.readOnly) {
throw new Error("READONLY=true blocks write operations.");
}
}
function assertDdlAllowed(client: SqlServerClient): void {
assertWritable(client);
if (!client.enableDdl) {
throw new Error("ENABLE_DDL=true is required for table and stored procedure DDL operations.");
}
}
function assertWriteLimit(client: SqlServerClient, rows: number): void {
if (rows > client.maxWriteRows) {
throw new Error(`Write affects ${rows} rows, which exceeds MAX_WRITE_ROWS=${client.maxWriteRows}.`);
}
}
function assertAffectedRowsWithinLimit(client: SqlServerClient, rowsAffected: number[]): void {
const total = rowsAffected.reduce((sum, count) => sum + count, 0);
assertWriteLimit(client, total);
}
async function insertRows(
client: SqlServerClient,
databaseName: string | undefined,
schemaName: string,
tableName: string,
rows: Record<string, unknown>[]
) {
const firstColumns = Object.keys(rows[0] ?? {});
if (firstColumns.length === 0) {
throw new Error("Insert rows must include at least one column.");
}
for (const row of rows) {
const columns = Object.keys(row);
if (columns.length !== firstColumns.length || columns.some((column) => !firstColumns.includes(column))) {
throw new Error("All insert rows must have the same columns.");
}
}
const params: SqlParam[] = [];
const values = rows.map((row, rowIndex) => {
const rowParams = firstColumns.map((column, columnIndex) => {
const name = `r${rowIndex}_${columnIndex}`;
params.push({ name, value: row[column] });
return `@${name}`;
});
return `(${rowParams.join(", ")})`;
});
return client.query(
databaseName,
`INSERT INTO ${qualifyName(schemaName, tableName)} (${firstColumns.map(quoteIdentifier).join(", ")}) VALUES ${values.join(", ")}`,
params
);
}
function buildAssignments(values: Record<string, unknown>, prefix: string) {
const entries = Object.entries(values).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
throw new Error("SET values must include at least one field.");
}
const params = entries.map(([key, value], index) => ({
name: `${prefix}${index}`,
value,
key
}));
return {
clause: params.map((param) => `${quoteIdentifier(param.key)} = @${param.name}`).join(", "),
params: params.map(({ name, value }) => ({ name, value }))
};
}
function assertSafeDataType(type: string): void {
const trimmed = type.trim();
if (!/^[a-zA-Z][a-zA-Z0-9\s(),]+$/u.test(trimmed)) {
throw new Error(`Unsafe SQL data type '${type}'.`);
}
if (/\b(drop|alter|create|insert|update|delete|exec|execute|merge|truncate)\b/iu.test(trimmed)) {
throw new Error(`Unsafe SQL data type '${type}'.`);
}
}