在设计 MCP Server 时,很多人会先想到 tools。比如连接 SQL Server 后,可以提供 list_tablesdescribe_tableexecute_queryget_procedure_definition 等工具。这样确实可以让 AI 查询数据库结构,也可以辅助分析 SQL 问题。但如果只使用 tools,会失去 MCP Resources 的一个重要价值:资源可以成为 Agent 客户端中的一等上下文对象。

现代 AI 编程工具,例如 Claude Code、VS Code Copilot Chat 等,在用户体验上已经不只是简单地展示“AI 调用了某个工具”。当 AI 读取一个文件时,客户端通常会显示“正在读取 file1”,用户点击这个文件名还能跳转到对应文件。这种体验背后有一个关键前提:被读取的对象不是一段普通文本,而是一个可识别、可定位、可展示的资源对象。对于数据库 MCP 来说,表、视图、存储过程、索引、触发器等对象,也应该尽量被设计成类似“文件”的资源,而不是隐藏在普通工具返回值里。

Resource 与 Tool 的边界

MCP 中的 Resource 更适合表达“已经存在、可以被引用、可以被读取的上下文对象”。例如:

mssql://local/db/sales/schema/dbo/table/Orders
mssql://local/db/sales/schema/dbo/procedure/GetOrders
mssql://local/db/sales/schema/dbo/view/OrderSummary
mssql://local/db/sales/schema/dbo/table/Orders/indexes

这些 URI 表示的是数据库中的稳定对象。用户可以通过 @ 引用它们,Agent 客户端也可以通过资源浏览、搜索、补全等方式把它们加入上下文。相比之下,Tool 更适合表达“动作”。例如搜索对象、解析依赖、执行只读查询、诊断 SQL 错误、分析执行计划等,都更适合作为工具。

因此,一个 SQL Server MCP 不应该只暴露 tools,也不应该把数据库中所有对象一股脑注册为 resources。更合理的设计是:用 resources 表达数据库对象,用 resource templates 表达可参数化的资源路径,用 tools 帮助模型发现、解析和读取相关资源。

代码层面可以先把资源注册集中起来,避免所有逻辑散落在 server 初始化代码中:

// resources/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

import { registerSqlEntryResources } from "./entries.js";
import { registerSqlResourceTemplates } from "./templates.js";
import { registerSqlResourceTools } from "./tools.js";

export function registerResources(server: McpServer) {
  registerSqlEntryResources(server);
  registerSqlResourceTemplates(server);
  registerSqlResourceTools(server);
}

这个结构的重点不是文件怎么命名,而是把三类事情分清楚:入口资源、动态资源模板、模型可调用的资源辅助工具。

不要把所有数据库对象平铺到 resources/list

SQL Server 中的对象数量可能非常多。一个实例下面可能有多个 database,每个 database 下有多个 schema,每个 schema 下又有大量 table、view、procedure、function、index。如果 resources/list 一次性返回所有对象,不仅性能差,也会让客户端资源列表变得不可用。

更优雅的做法是让 resources/list 只返回少量入口资源或高价值资源。例如:

mssql://local
mssql://local/db/sales
mssql://local/db/sales/schemas
mssql://local/db/sales/schema/dbo
mssql://local/db/sales/schema/dbo/tables
mssql://local/db/sales/schema/dbo/views
mssql://local/db/sales/schema/dbo/procedures

这些资源更像目录入口。用户或客户端读取 mssql://local/db/sales/schema/dbo/tables 时,Server 可以返回该 schema 下的表清单;读取具体表 URI 时,Server 再返回表结构、列、主键、外键、索引摘要、行数估计等内容。

入口资源可以这样写:

// resources/entries.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

export function registerSqlEntryResources(server: McpServer) {
  server.registerResource(
    "SQL Server Local",
    "mssql://local",
    {
      title: "SQL Server Local",
      description: "Root resource for the local SQL Server instance.",
      mimeType: "text/markdown",
    },
    async (uri) => ({
      contents: [
        {
          uri: uri.toString(),
          mimeType: "text/markdown",
          text: [
            "# SQL Server: local",
            "",
            "Available databases:",
            "",
            "- sales",
            "- hr",
            "- finance",
            "",
            "Use resource templates to access schemas, tables, views, and procedures.",
          ].join("\n"),
        },
      ],
    })
  );

  server.registerResource(
    "Sales Database",
    "mssql://local/db/sales",
    {
      title: "sales",
      description: "Entry resource for the sales database.",
      mimeType: "text/markdown",
    },
    async (uri) => ({
      contents: [
        {
          uri: uri.toString(),
          mimeType: "text/markdown",
          text: [
            "# Database: sales",
            "",
            "Common entry points:",
            "",
            "- mssql://local/db/sales/schemas",
            "- mssql://local/db/sales/schema/dbo/tables",
            "- mssql://local/db/sales/schema/dbo/views",
            "- mssql://local/db/sales/schema/dbo/procedures",
          ].join("\n"),
        },
      ],
    })
  );
}

这里的关键点是:静态注册的 resource 不需要覆盖整个数据库。它们更像“导航入口”。真正大量的数据库对象,应该交给 Resource Template 动态读取。

例如:

mssql://local/db/sales/schema/dbo/tables

可以返回:

# Tables in dbo

- dbo.Customers
- dbo.Orders
- dbo.OrderItems
- dbo.Products

而:

mssql://local/db/sales/schema/dbo/table/Orders

可以返回:

CREATE TABLE dbo.Orders (
    OrderId int NOT NULL PRIMARY KEY,
    CustomerId int NOT NULL,
    CreatedAt datetime2 NOT NULL,
    Status nvarchar(32) NOT NULL
);

-- Foreign Keys
-- FK_Orders_Customers: CustomerId -> dbo.Customers.CustomerId

-- Indexes
-- PK_Orders
-- IX_Orders_CustomerId
-- IX_Orders_CreatedAt

这种方式让 resources/list 保持轻量,同时保留资源浏览的层次感。

使用 Resource Templates 表达动态资源

大量数据库对象不适合全部预注册,但它们适合通过 Resource Template 暴露。Resource Template 可以告诉客户端:Server 支持某一类 URI 结构,客户端可以根据参数构造具体资源。

对于 SQL Server MCP,可以设计如下模板:

mssql://{server}/db/{database}
mssql://{server}/db/{database}/schema/{schema}
mssql://{server}/db/{database}/schema/{schema}/table/{table}
mssql://{server}/db/{database}/schema/{schema}/view/{view}
mssql://{server}/db/{database}/schema/{schema}/procedure/{procedure}
mssql://{server}/db/{database}/schema/{schema}/function/{function}
mssql://{server}/db/{database}/schema/{schema}/table/{table}/indexes
mssql://{server}/db/{database}/schema/{schema}/table/{table}/sample?limit={limit}

代码可以这样表达:

// resources/templates.ts
import {
  McpServer,
  ResourceTemplate,
} from "@modelcontextprotocol/sdk/server/mcp.js";

export function registerSqlResourceTemplates(server: McpServer) {
  server.registerResource(
    "SQL Table",
    new ResourceTemplate(
      "mssql://{server}/db/{database}/schema/{schema}/table/{table}",
      {
        list: undefined,
        complete: {
          server: completeServers,
          database: completeDatabases,
          schema: completeSchemas,
          table: completeTables,
        },
      }
    ),
    {
      title: "SQL Table",
      description: "SQL Server table schema resource.",
      mimeType: "application/sql",
    },
    async (uri, variables) => {
      const ddl = await getTableDefinition({
        serverName: String(variables.server),
        database: String(variables.database),
        schema: String(variables.schema),
        table: String(variables.table),
      });

      return {
        contents: [
          {
            uri: uri.toString(),
            mimeType: "application/sql",
            text: ddl,
          },
        ],
      };
    }
  );

  server.registerResource(
    "SQL Procedure",
    new ResourceTemplate(
      "mssql://{server}/db/{database}/schema/{schema}/procedure/{procedure}",
      {
        list: undefined,
        complete: {
          server: completeServers,
          database: completeDatabases,
          schema: completeSchemas,
          procedure: completeProcedures,
        },
      }
    ),
    {
      title: "SQL Procedure",
      description: "SQL Server stored procedure definition resource.",
      mimeType: "application/sql",
    },
    async (uri, variables) => {
      const sql = await getProcedureDefinition({
        serverName: String(variables.server),
        database: String(variables.database),
        schema: String(variables.schema),
        procedure: String(variables.procedure),
      });

      return {
        contents: [
          {
            uri: uri.toString(),
            mimeType: "application/sql",
            text: sql,
          },
        ],
      };
    }
  );
}

这段代码体现了一个重要原则:tableprocedure 本身就是 resource。它们不是 describe_table 工具返回的一段普通文本,而是有 URI、有 MIME 类型、有标题和描述的上下文对象。

但是需要注意,Resource Template 本身只说明“可以这样访问资源”,不等于客户端一定会自动提供完美的路径补全。路径补全、树形浏览、搜索体验,仍然取决于具体 Agent 客户端的实现。

用 Completion 支持类似文件路径的补全体验

如果希望用户输入 @mssql:// 时像输入文件路径一样逐层补全,MCP Server 需要配合实现 completion 能力。比如用户输入:

@mssql://

客户端可以请求 server 参数补全,Server 返回:

local
dev
prod

用户选择 local 后继续输入:

@mssql://local/db/

Server 再返回:

sales
hr
finance

继续选择 sales 后,Server 返回 schema:

dbo
reporting
audit

最后补全 table:

Customers
Orders
OrderItems
Products

对应代码可以先写成简单版本:

async function completeServers(value: string) {
  const servers = ["local", "dev", "prod"];
  return servers.filter((x) => x.startsWith(value));
}

async function completeDatabases(value: string, context?: any) {
  const serverName = context?.arguments?.server ?? "local";
  const databases = await listDatabases(serverName);
  return databases.filter((x) => x.startsWith(value));
}

async function completeSchemas(value: string, context?: any) {
  const serverName = context?.arguments?.server ?? "local";
  const database = context?.arguments?.database;

  if (!database) return [];

  const schemas = await listSchemas(serverName, database);
  return schemas.filter((x) => x.startsWith(value));
}

async function completeTables(value: string, context?: any) {
  const serverName = context?.arguments?.server ?? "local";
  const database = context?.arguments?.database;
  const schema = context?.arguments?.schema;

  if (!database || !schema) return [];

  const tables = await listTables(serverName, database, schema);
  return tables.filter((x) => x.startsWith(value));
}

async function completeProcedures(value: string, context?: any) {
  const serverName = context?.arguments?.server ?? "local";
  const database = context?.arguments?.database;
  const schema = context?.arguments?.schema;

  if (!database || !schema) return [];

  const procedures = await listProcedures(serverName, database, schema);
  return procedures.filter((x) => x.startsWith(value));
}

这个流程中,MCP Server 负责提供可补全的数据,Agent Client 负责识别用户正在输入资源引用,并决定何时调用补全接口、如何展示结果、是否缓存结果、是否显示成树形结构。

换句话说,MCP Server 提供“资源语义”和“补全能力”,但 @mssql:// 的最终交互体验,是 MCP Server 和 Agent Client 共同完成的。

仅靠 Resource 还不够:模型也需要主动探索资源

有一个更复杂但非常真实的场景:

用户:帮我看看 @mssql://local/db/sales/schema/dbo/procedure/GetOrders 为什么出错。

用户手动把 GetOrders 这个存储过程作为资源加入上下文。Agent 读取了 procedure 的代码,发现里面引用了 dbo.Ordersdbo.Customers。这时模型需要继续查看这些表的定义,才能判断错误原因。

问题来了:模型怎么知道 dbo.Orders 的资源 URI?又应该通过什么方式读取它?

从 MCP 的语义上看,resources/read 是 Client/Host 调用 MCP Server 的通用资源读取能力,而 Tool 才是模型主动调用的动作。如果 Agent Host 没有把通用 read_resource(uri) 暴露给模型,那么模型虽然知道自己需要更多上下文,却不一定能主动读取相关资源。

因此,SQL Server MCP 最好不要只提供静态 resources,还应该提供一组面向模型探索的工具,例如:

resolve_database_object(name, contextUri)
get_object_dependencies(objectUri)
get_referenced_by(objectUri)
search_database_objects(query, contextUri)
read_database_resource(uri)

这些工具不是为了取代 resources,而是为了让模型能够在分析过程中发现和请求更多 resources。

例如,模型看到存储过程里有 dbo.Orders,可以先调用对象解析工具:

server.registerTool(
  "resolve_database_object",
  {
    title: "Resolve SQL Object",
    description: "Resolve a SQL object name to a MCP resource URI.",
    inputSchema: {
      name: z.string(),
      contextUri: z.string().optional(),
    },
  },
  async ({ name, contextUri }) => {
    const object = await resolveObjectName(name, contextUri);

    return {
      content: [
        {
          type: "text",
          text: `Resolved ${name} to ${object.uri}`,
        },
        {
          type: "resource_link",
          uri: object.uri,
          name: object.name,
          title: object.title,
          description: object.description,
          mimeType: object.mimeType,
        },
      ],
      structuredContent: object,
    };
  }
);

这里的工具不是直接返回表结构,而是把 dbo.Orders 解析成一个真正的 resource URI。这样模型可以继续请求读取它,客户端也有机会显示这个资源链接。

Resource Bridge Tool:让工具结果仍然保持资源语义

这里最关键的是 read_database_resource(uri)。它看起来和 MCP 自带的 resources/read 有点重复,但两者服务的对象不同。

resources/read 是 Host/Client 读取资源的协议能力;read_database_resource(uri) 是模型可以主动调用的工具。它的意义不是重新发明资源读取,而是在当前客户端还没有通用模型可控资源读取能力时,提供一个桥接层。

不过,这个工具不应该返回普通文本。错误的做法是:

{
  "content": [
    {
      "type": "text",
      "text": "CREATE TABLE dbo.Orders (...)"
    }
  ]
}

这样客户端只能看到一次普通工具调用,很难知道 AI 实际上读取的是 dbo.Orders 这个资源。

更好的做法是让工具结果携带资源语义,例如返回 embedded resource 或 resource link:

server.registerTool(
  "read_database_resource",
  {
    title: "Read SQL Resource",
    description: "Read a SQL Server MCP resource by URI.",
    inputSchema: {
      uri: z.string(),
    },
  },
  async ({ uri }) => {
    const resource = await readSqlResourceByUri(uri);

    return {
      content: [
        {
          type: "resource",
          resource: {
            uri,
            mimeType: resource.mimeType,
            text: resource.text,
          },
        },
      ],
      structuredContent: {
        uri,
        kind: resource.kind,
        database: resource.database,
        schema: resource.schema,
        name: resource.name,
      },
    };
  }
);

对应返回结果可以理解成:

{
  "content": [
    {
      "type": "resource",
      "resource": {
        "uri": "mssql://local/db/sales/schema/dbo/table/Orders",
        "mimeType": "application/sql",
        "text": "CREATE TABLE dbo.Orders (...);"
      }
    }
  ],
  "structuredContent": {
    "uri": "mssql://local/db/sales/schema/dbo/table/Orders",
    "kind": "table",
    "database": "sales",
    "schema": "dbo",
    "name": "Orders"
  }
}

这样做的好处是,客户端有机会把它渲染成“AI 正在读取 dbo.Orders”,并且可以把 dbo.Orders 显示成可点击、可追踪的资源对象。即使某些客户端暂时没有完全支持这种展示方式,MCP Server 也已经提供了更标准的资源语义,而不是把所有东西降级成普通文本。

会话级资源:让分析结果也能被引用

除了数据库中原本存在的对象,Agent 工作过程中还会产生一些临时结果。例如诊断报告、依赖分析报告、查询计划解释、schema diff 等。这些结果也可以作为 session-scoped resource 暴露,而不是只作为一次性文本输出。

例如,diagnose_procedure_error 工具可以生成一份诊断报告,并返回一个资源链接:

const sessionResources = new Map<string, string>();

server.registerTool(
  "diagnose_procedure_error",
  {
    title: "Diagnose SQL Procedure Error",
    description: "Analyze a stored procedure error and create a diagnosis report.",
    inputSchema: {
      procedureUri: z.string(),
      errorMessage: z.string(),
    },
  },
  async ({ procedureUri, errorMessage }) => {
    const report = await buildDiagnosisReport(procedureUri, errorMessage);
    const reportUri = `mssql://session/diagnosis/${Date.now()}`;

    sessionResources.set(reportUri, report);

    server.registerResource(
      "SQL Diagnosis Report",
      reportUri,
      {
        title: "SQL Diagnosis Report",
        description: `Diagnosis report for ${procedureUri}`,
        mimeType: "text/markdown",
      },
      async (uri) => ({
        contents: [
          {
            uri: uri.toString(),
            mimeType: "text/markdown",
            text: sessionResources.get(uri.toString()) ?? "",
          },
        ],
      })
    );

    return {
      content: [
        {
          type: "text",
          text: "Created a diagnosis report.",
        },
        {
          type: "resource_link",
          uri: reportUri,
          name: "SQL Diagnosis Report",
          title: "SQL Diagnosis Report",
          description: `Diagnosis report for ${procedureUri}`,
          mimeType: "text/markdown",
        },
      ],
    };
  }
);

这样,工具生成的分析结果也不只是聊天里的普通文字,而是一个可以被客户端展示、再次读取、再次引用的资源对象。这对于长任务尤其重要,因为用户和模型都可能需要回到某个中间分析结果。

推荐的 SQL Server MCP 资源设计

一个比较完整的 SQL Server MCP 可以按下面方式组织。

首先,resources/list 只返回少量入口资源:

mssql://local
mssql://local/db/sales
mssql://local/db/sales/schemas
mssql://local/db/sales/schema/dbo
mssql://local/db/sales/schema/dbo/tables
mssql://local/db/sales/schema/dbo/views
mssql://local/db/sales/schema/dbo/procedures

其次,通过 resources/templates/list 暴露动态资源形状:

mssql://{server}/db/{database}
mssql://{server}/db/{database}/schema/{schema}
mssql://{server}/db/{database}/schema/{schema}/table/{table}
mssql://{server}/db/{database}/schema/{schema}/view/{view}
mssql://{server}/db/{database}/schema/{schema}/procedure/{procedure}
mssql://{server}/db/{database}/schema/{schema}/function/{function}
mssql://{server}/db/{database}/schema/{schema}/table/{table}/indexes

然后,实现 completion 支持逐层补全:

server -> database -> schema -> object type -> object name

最后,提供少量模型可调用的数据库语义工具:

resolve_database_object(name, contextUri)
get_object_dependencies(objectUri)
get_referenced_by(objectUri)
search_database_objects(query, contextUri)
read_database_resource(uri)
diagnose_procedure_error(procedureUri, errorMessage)
run_readonly_query(sql)

其中,resolve_database_object 可以把 Orders 解析成完整 URI:

{
  "name": "dbo.Orders",
  "kind": "table",
  "uri": "mssql://local/db/sales/schema/dbo/table/Orders"
}

get_object_dependencies 可以返回依赖资源:

{
  "object": {
    "uri": "mssql://local/db/sales/schema/dbo/procedure/GetOrders"
  },
  "dependencies": [
    {
      "name": "dbo.Orders",
      "kind": "table",
      "uri": "mssql://local/db/sales/schema/dbo/table/Orders"
    },
    {
      "name": "dbo.Customers",
      "kind": "table",
      "uri": "mssql://local/db/sales/schema/dbo/table/Customers"
    }
  ]
}

read_database_resource 则负责把某个 URI 读取成带资源语义的结果,而不是普通文本。

一个完整交互流程

假设用户提出问题:

帮我看看 @mssql://local/db/sales/schema/dbo/procedure/GetOrders 为什么出错。

比较理想的流程如下:

1. 用户通过 @ 引用 GetOrders。
2. Agent Client 调用 resources/read 读取 GetOrders。
3. 模型看到 GetOrders 中引用了 dbo.Orders 和 dbo.Customers。
4. 模型调用 get_object_dependencies,确认依赖对象。
5. 工具返回 dbo.Orders 和 dbo.Customers 的 resource URI。
6. 模型调用 read_database_resource 读取相关表定义。
7. 工具返回 embedded resource,而不是普通文本。
8. 客户端显示 AI 正在读取 dbo.Orders、dbo.Customers。
9. 模型结合 procedure 和 table schema 分析错误原因。

这个流程里,resources 负责让数据库对象成为可引用上下文,tools 负责让模型主动探索上下文,embedded resource / resource link 负责让工具调用仍然保留资源语义。三者结合起来,才能接近现代 AI 编程工具中“读取文件”的体验。

为什么不要只做 Tools

如果 SQL Server MCP 只提供 tools,例如:

list_tables
describe_table
get_procedure
search_columns

模型当然也能工作。但客户端看到的只是工具调用,用户体验会退化成:

AI called describe_table
AI called get_procedure
AI called search_columns

这和:

AI is reading dbo.Orders
AI is reading dbo.Customers
AI is reading dbo.GetOrders

是不一样的。

后者更接近现代 Agent IDE 的体验。用户知道 AI 正在看哪些对象,也可以点击这些对象查看详情。对于数据库、文档系统、企业知识库、代码仓库来说,这种“可追踪的上下文读取”非常重要。

为什么也不要只做 Resources

反过来,如果 SQL Server MCP 只提供 resources,也会遇到问题。用户可以手动引用 @procedure1,但模型分析过程中发现还需要 table1view1function1 时,不一定有办法主动读取它们。除非 Agent Host 提供了通用的模型可控资源读取能力,否则模型仍然需要 tools 来搜索、解析和请求相关资源。

所以更现实的结论不是“resources 和 tools 二选一”,而是:

Resources 负责表达对象。
Resource Templates 负责表达对象空间。
Completion 负责改善用户输入和资源发现体验。
Tools 负责让模型主动探索、解析和诊断。
ResourceLink / Embedded Resource 负责让工具结果保留资源语义。

结论

一个优雅的 SQL Server MCP,不应该把数据库对象全部塞进 resources/list,也不应该只用一堆普通 tools 暴露数据库能力。更好的设计是把数据库对象当作资源,把数据库操作当作工具,把两者通过 URI、ResourceLink 和 embedded resource 连接起来。

对于表、视图、存储过程、函数、索引等稳定对象,应该优先设计为 MCP resources。对于搜索、依赖分析、对象解析、错误诊断、只读 SQL 执行等动态行为,应该设计为 tools。对于模型主动读取资源的需求,可以提供 read_database_resource(uri) 这样的桥接工具,但它的返回结果应该携带 MCP 资源语义,而不是普通文本。

这样设计之后,SQL Server MCP 才不仅仅是一个“数据库查询工具集合”,而是一个真正适合 Agent 使用的上下文资源系统。它既支持用户通过 @ 引用数据库对象,也支持模型在分析过程中主动发现更多上下文,还为客户端提供了展示“AI 正在读取哪个数据库对象”的可能性。


<
Previous Post
面向 AI Agent 的能力模型与多协议生成架构设计思考
>
Next Post