Part of: AI and search over SharePoint data

An MCP server for SharePoint data that queries SQL

Claude, ChatGPT or Cursor reading a read-only SQL replica of SharePoint lists through a database MCP server, with no Graph permission of its own.

Tags: Relational databasesAIDesktopCommand line

The request usually arrives as one line in a Teams chat. The head of sales wants to ask Claude “what’s slipping this quarter?” and get an answer from the Opportunities list, the one with a lookup to Accounts that the whole sales operations team updates every day. Sometimes it is ChatGPT, or a developer who wants the same list inside Cursor. Somebody searches “connect ChatGPT to SharePoint” or “Claude SharePoint list”, finds a SharePoint MCP server on GitHub, registers an app in Entra, and the first demo goes well on a test site with forty rows.

Then it meets the real list. The assistant pages through items one request at a time, gets back AccountLookupId where it expected a company name, hits a filter that refuses to run past the 5,000-item view threshold, and starts collecting 429 responses halfway through a question about pipeline by stage. Meanwhile the security team has asked why an experiment needs tenant-wide read access. We have watched this happen more than once, and the fix is the same each time: give the assistant a database to query.

Where a SharePoint MCP server struggles

A SharePoint MCP server is a thin wrapper over Microsoft Graph or the SharePoint REST API. Those APIs were built to show a page of items to a person in a browser. An assistant answering a business question wants something else: totals, groupings, joins between lists, and “which of these has not changed in a month”. Graph has no GROUP BY and no join, so the server has to fetch every relevant item and hand the lot to the model, which then adds up currency values in its context window. Models are poor at long arithmetic, and a few thousand opportunities fill the window.

The shape of the data makes it worse. A lookup column comes back as an id, sometimes with the display text alongside and sometimes without. A person column is a LookupId into the site’s hidden User Information List, which the model then has to resolve with more calls. Multi-value columns arrive as arrays or as ;#-delimited strings, depending on which API the server wraps. Filtering on a column that is not indexed fails on a large list unless the request carries Graph’s Prefer: HonorNonIndexedQueriesWarningMayFailRandomly header, and the header’s name tells you how much to trust it.

Then there is throttling. Microsoft Learn’s page “Avoid getting throttled or blocked in SharePoint Online” describes what happens when an application sends too much: SharePoint answers with 429 or 503 and a Retry-After header, requests sent while throttled still count against the limit, and an application that keeps ignoring the header can be blocked. An agent that retries in a loop is that application. It shares a tenant budget with your intranet, so a bad afternoon for the assistant becomes everyone’s.

What Graph permissions does a SharePoint MCP server need, and who signs that off?

Most SharePoint MCP servers ask for Sites.Read.All, and the ones that offer create or update tools ask for Sites.ReadWrite.All. With delegated permissions the server acts as the signed-in user and sees every site that person can open, which for a sales director is usually a great deal more than the Opportunities list. With application permissions it sees every site in the tenant, and an Entra administrator has to grant admin consent. Sites.Selected narrows an application to named sites, but only if the server supports it and only after an administrator grants each site through Graph.

The sign-off falls to whoever owns Entra and SharePoint security, and their questions are fair: what the identity can write, where its secret or token is stored, and what stops a prompt injected into a list item from telling the model to call the update tool. A broad grant to a new, non-deterministic client is a hard thing to approve, and a narrow grant to a server that was never designed for one is hard to configure. Many pilots stop at this point.

Do the ChatGPT and Claude SharePoint connectors read list items?

Before anyone installs an MCP server, most teams try the connector their AI tool already offers. Both of the big ones are built for documents. Anthropic’s help article “Set up the Microsoft 365 connector” says Claude searches SharePoint, OneDrive, Outlook and Teams as the signed-in user, and names the file types it reads from SharePoint and OneDrive: Word, Excel, PowerPoint, PDF and plain text. OpenAI’s article “SharePoint app and setup in ChatGPT” describes the same shape. Search covers .txt, .pdf, .docx, .pptx, .xlsx and .csv files, an Entra administrator approves Sites.Read.All and Files.Read.All, and the article states that Sites.Selected is not supported for live SharePoint search. Working with lists appears there only among optional actions that need Sites.ReadWrite.All.

So a search for “connect ChatGPT to SharePoint” has a good answer for a folder of proposals and a poor one for “total the Opportunities list by stage”. Cursor reaches outside data through the MCP servers you add to it. For all three tools the route that works on list rows is the same one: a database MCP server, read only, over a copy of the lists.

Why a database MCP server behaves better

Database MCP servers are a mature category. The Model Context Protocol project’s reference Postgres server is the one most tutorials start with, and it has since been archived. Across the maintained servers you find read-only modes, checks that a statement is a SELECT, automatic row limits, connection pooling and schema introspection. Claude Desktop, ChatGPT, Cursor and most agent frameworks already speak to them.

The model is also good at SQL. Given a schema with clear table and column names, it writes the grouping and the date arithmetic itself, the database does the counting, and only the result comes back into the conversation. A question about pipeline by stage becomes one query that returns a few rows.

One caution from experience: the server’s read-only switch is a convenience, and the database role is the real boundary. In August 2025 Datadog Security Labs, in “MCP vulnerability case study: SQL injection in the Postgres MCP server”, showed that the reference server’s read-only transaction could be closed by the SQL it was asked to run, after which later statements ran with whatever rights the connection had. A role that holds SELECT on a few views, and owns nothing, is safe whatever the server does.

Postgres and SQL Server MCP servers that run read only

Two servers cover the two destinations most teams choose, and each has a read-only setting you can name in a security review. Neither is ours: SPSync has its own read-only server, spsync mcp serve, but these two are third-party or Microsoft open-source projects you install yourself.

DBHub, from Bytebase, speaks Postgres, SQL Server, MySQL, MariaDB, Oracle and SQLite from one process, and it is the server the session below uses. Read-only is set per tool in its dbhub.toml, which its documentation says is enforced twice, by a keyword check and by the database’s own read-only mode, and max_rows caps what a query returns. We ran version 1.5.0 with readonly = true: a SELECT came back with its rows and an UPDATE came back as READONLY_VIOLATION. For a SQL Server replica:

[[sources]]
id = "sales_replica"
dsn = "sqlserver://sales_agent:${SALES_AGENT_PASSWORD}@replica.internal:1433/sales_replica"
[[tools]]
name = "execute_sql"
source = "sales_replica"
readonly = true
max_rows = 500

Microsoft’s SQL MCP Server, part of Data API builder from version 1.7, takes another line: the model never writes SQL. You declare the tables and views it may expose as entities, give each role the actions it may take, and the server offers tools such as describe_entities, read_records and aggregate_records. Give the assistant’s role read and nothing else:

Terminal window
dab init --database-type mssql --connection-string "@env('SALES_REPLICA')"
dab add Opportunities --source agent.opportunities --source.type view \
--source.key-fields "opportunity_id" \
--permissions "sales_agent:read" --description "Open and closed sales deals"
dab start --mcp-stdio role:sales_agent

The connection string should still log in as a database user that can only read. With either server, the server’s setting is the first fence and the database grant is the one that holds.

You may also meet Postgres MCP Pro, from Crystal DBA, in older tutorials, including an earlier version of this post. Its restricted mode runs only read-only transactions, but on 23 October 2026 its current release on PyPI, 0.3.0, would not install: it requires version 7.2.0 of its SQL parser, pglast, and PyPI no longer has that version. The only release that still installs, 0.1.0, stops at start-up with the current MCP library. If uvx postgres-mcp fails for you with a resolver error, that is the cause, and DBHub is the server to use until Crystal DBA publishes a fix. If your agent is built in a framework, the guide to SharePoint lists in LangChain, LlamaIndex, Semantic Kernel, CrewAI, AutoGen and n8n loads the same DBHub server into each one.

Replicating only the lists the assistant needs

SPSync reads the lists you choose through Graph and keeps a copy current, on a schedule, in a database you own. It never writes to SharePoint. For the sales team, that means one site and two lists.

Start with permissions. You can try it with delegated sign-in, where SPSync sees only what you can see. For an unattended schedule, ask the SharePoint administrator to grant the SPSync app Sites.Selected on the sales site and nothing else; the desktop app’s permissions wizard produces a one-page admin pack with the exact grant to run. The assistant itself never gets a Graph permission of any kind.

Next, pick the lists. In the desktop app you connect the site, tick Opportunities and Accounts, choose PostgreSQL as the destination, and check the schema preview before anything runs. From the command line the same choice is one config file:

Terminal window
spsync export-config --output sales-agent.yaml --pipeline-name sales_agent \
--prefix sales --destination-kind postgres \
--connection-string-env SALES_REPLICA_URL --dataset-name sharepoint \
--client-id $SPSYNC_CLIENT_ID \
--site-url https://contoso.sharepoint.com/sites/sales \
--lists-by-name Opportunities,Accounts
spsync run sales-agent.yaml

The first run is a full load; after that each run reads only the items that changed. In the sharepoint schema you get sales_opportunities and sales_accounts, one row per item. The Account lookup becomes account_id, which joins to sales_accounts.sp_item_id, plus account_value with the display text. Owner, a person column, becomes owner_user_id, which joins to one shared sp_users table holding names and email addresses. The multi-choice Products column becomes its own child table, sales_opportunities__products, with one row per opportunity and product. A deleted item keeps its row with sp_deleted_at set, so “what dropped out of the pipeline?” has an answer.

Set the schedule to every few minutes and the replica stays close to the list. The desktop app syncs in the background while the machine is on; SPSync in your Azure runs in your own subscription around the clock, for an assistant used at all hours.

How do you scope the assistant’s database role?

Keep the replica in its own database. SPSync writes to the sharepoint schema, and the assistant reads a small set of views in public, the schema most database MCP servers list first. The views rename columns into plain business terms, resolve the owner’s name, hide soft-deleted rows and carry the sync time:

-- As an administrator. spsync_loader is the login SPSync writes with;
-- it owns every table in the sharepoint schema.
REVOKE ALL ON DATABASE sales_replica FROM PUBLIC;
GRANT CONNECT ON DATABASE sales_replica TO spsync_loader;
-- SPSync creates the sharepoint and sharepoint_staging schemas on its first run
GRANT CREATE ON DATABASE sales_replica TO spsync_loader;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
GRANT CREATE ON SCHEMA public TO spsync_loader;
CREATE ROLE sales_agent LOGIN PASSWORD 'change-me';
GRANT CONNECT ON DATABASE sales_replica TO sales_agent;
ALTER ROLE sales_agent SET default_transaction_read_only = on;
ALTER ROLE sales_agent SET statement_timeout = '15s';
-- As spsync_loader, so each view can read the tables it selects from.
-- The assistant gets the views and never the sharepoint schema itself.
CREATE VIEW public.opportunities AS
SELECT o.sp_item_id AS opportunity_id,
o.title AS opportunity,
o.account_id,
o.account_value AS account,
o.stage,
o.amount,
o.close_date,
u.display_name AS owner,
o.sp_modified AS last_modified,
o.sp_synced_at AS synced_at
FROM sharepoint.sales_opportunities o
LEFT JOIN sharepoint.sp_users u ON u.id = o.owner_user_id
WHERE o.sp_deleted_at IS NULL;
CREATE VIEW public.accounts AS
SELECT a.sp_item_id AS account_id,
a.title AS account,
a.region,
a.industry,
u.display_name AS account_manager
FROM sharepoint.sales_accounts a
LEFT JOIN sharepoint.sp_users u ON u.id = a.account_manager_user_id
WHERE a.sp_deleted_at IS NULL;
CREATE VIEW public.opportunity_products AS
SELECT p.sp_item_id AS opportunity_id, p.value AS product
FROM sharepoint.sales_opportunities__products p
JOIN sharepoint.sales_opportunities o ON o.sp_item_id = p.sp_item_id
WHERE o.sp_deleted_at IS NULL;
CREATE VIEW public.removed_opportunities AS
SELECT sp_item_id AS opportunity_id, title AS opportunity,
account_value AS account, stage, amount, sp_deleted_at AS removed_at
FROM sharepoint.sales_opportunities
WHERE sp_deleted_at IS NOT NULL;
GRANT SELECT ON public.opportunities, public.accounts,
public.opportunity_products, public.removed_opportunities
TO sales_agent;

The grants do the work. default_transaction_read_only and the statement timeout are defaults a session could change, so we treat them as guard rails; the boundary is that sales_agent owns nothing and holds SELECT on four views. Because the views belong to spsync_loader, they read the tables with its rights while the assistant holds none. The assistant cannot see email addresses, because no view exposes them, and it has no access to the sharepoint schema or to sharepoint_staging, where SPSync stages changes before merging them.

When an assistant should see a subset of rows, the plainest answer is a role per audience with views that filter, for example an emea_agent role whose opportunities view joins to accounts with region = 'EMEA'. When several assistants and reporting logins share the same tables, Postgres row-level security does it once. Enable it on both tables, give each role a policy on sales_accounts (region = 'EMEA') and a policy on sales_opportunities that keeps only rows whose account_id is in the accounts that role can see, then create the views WITH (security_invoker = true) (PostgreSQL 15 and later) so the policies are checked against the assistant rather than the view’s owner. The role then needs SELECT on the underlying tables as well as the views. The SPSync loader owns the tables it creates, so policies do not affect sync unless you add FORCE ROW LEVEL SECURITY. Once row-level security is on, every other non-owner role sees no rows until it has a policy, including the Power BI login.

On SQL Server the pattern is the same with different syntax. Point --dataset-name at a schema such as sharepoint, create the views in an agent schema, GRANT SELECT ON SCHEMA::agent to a contained database user, and use a security policy with an inline predicate function where you need row filtering. On SQLite there are no roles, so the file is the boundary: replicate only the assistant’s lists into a file of its own and have the MCP server open it read-only. SPSync writes SQLite in WAL mode, so the assistant’s reads are not blocked while a sync is writing.

A session with the assistant

DBHub reads the Postgres replica from a dbhub.toml beside the user’s Claude settings, logging in as the sales_agent role from the previous section:

[[sources]]
id = "sales_replica"
dsn = "postgres://sales_agent:${SALES_AGENT_PASSWORD}@replica.internal:5432/sales_replica?sslmode=require"
[[tools]]
name = "execute_sql"
source = "sales_replica"
readonly = true
max_rows = 500

Claude Desktop reads its MCP servers from claude_desktop_config.json, which lives under ~/Library/Application Support/Claude/ on macOS and %APPDATA%\Claude\ on Windows. The entry for the replica looks like this, with the full path to dbhub.toml:

{
"mcpServers": {
"sales-replica": {
"command": "npx",
"args": ["-y", "@bytebase/[email protected]", "--config", "/path/to/dbhub.toml"],
"env": {
"SALES_AGENT_PASSWORD": "<password>"
}
}
}
}

Put the real password where <password> is. The file then holds it, so it belongs on the user’s own machine and nowhere else. Cursor reads the same mcpServers block from .cursor/mcp.json in a project. ChatGPT works differently: it connects only to a remote MCP server, added by an administrator, or a member they authorise, in a Business, Enterprise or Edu workspace, through a public HTTPS address or a secure tunnel. For ChatGPT, run DBHub over HTTP behind your own authentication and keep the database itself off the internet. In the Claude project’s instructions we add two lines: answer sales questions from the sales replica, and state the synced_at time with every answer.

Then the sales director asks, “What’s our open pipeline by stage for deals closing this quarter?” Claude reads the view definitions and writes:

SELECT stage, count(*) AS deals, sum(amount) AS pipeline
FROM opportunities
WHERE stage NOT IN ('Closed Won', 'Closed Lost')
AND close_date >= date_trunc('quarter', current_date)
AND close_date < date_trunc('quarter', current_date) + interval '3 months'
GROUP BY stage
ORDER BY pipeline DESC;

A handful of rows come back and Claude turns them into a short table with a sentence about where the money sits. The follow-up is the one that would have meant paging both lists through Graph: “Which EMEA accounts have open deals that have not been touched in a month?”

SELECT a.account, a.account_manager,
count(*) AS open_deals, max(o.last_modified) AS last_touched
FROM opportunities o
JOIN accounts a ON a.account_id = o.account_id
WHERE a.region = 'EMEA'
AND o.stage NOT IN ('Closed Won', 'Closed Lost')
GROUP BY a.account, a.account_manager
HAVING max(o.last_modified) < now() - interval '30 days'
ORDER BY last_touched;

The join uses the lookup id, the account manager arrives as a name, and the database does the date comparison. Questions about products use the child table (“which products show up most in deals we won this year?”) and questions about churn in the list use removed_opportunities (“did anything disappear from the pipeline this week?”). None of them sends a request to SharePoint.

Writing back to SharePoint

SPSync is read-only by design, and for an assistant we think that is the right default. Reading a replica cannot change a deal’s stage, whatever the model was told by a prompt hidden in a list item. If the team later wants the assistant to update a stage, build that as a separate, deliberate tool: a Power Automate flow or a small API that writes one column on one list, with its own narrow permission and a person approving each change.

A Graph-based MCP server is still the right choice in a few cases. If people ask about the contents of documents, the replica does not help, because SPSync replicates document-library metadata and not file contents. For a one-person pilot on a small list, delegated access through an existing server is quicker to try. For anything a team relies on, with lists in the thousands and a security review to pass, the replica is easier to approve and cheaper to run.

To set this up, start with the destination you already run: SharePoint to PostgreSQL, SharePoint to SQL Server or SharePoint to SQLite. Giving agents SQL tools over SharePoint data covers the editions, and licensing explains the per-installation, per-destination-family licence. If the same replica also feeds reports, Power BI on SharePoint lists and the seven-step SQL Server setup walk through the reporting side.

When more than one agent needs the same lists, One SQL copy of SharePoint for every agent you run works through the Graph cost of a cross-list question and how several agents share one replica.

Book a call

Book a call to talk through your use case, see further walkthroughs or demos, and set up a trial in your own environment.