Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ SQLSERVER_CONNECTION_TIMEOUT=30000
SQLSERVER_REQUEST_TIMEOUT=60000
SQLSERVER_MAX_ROWS=1000

# Row-Level Security - applied to every query via sp_set_session_context.
# Values may be strings or numbers; numbers are bound as int.
# SQLSERVER_SESSION_CONTEXT={"TenantId":42,"UserRole":"auditor"}
# SQLSERVER_SESSION_CONTEXT_READONLY=true

# Example configurations for different environments:

# Azure SQL Database
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,33 @@ The server is configured using environment variables:
- `SQLSERVER_CONNECTION_TIMEOUT` - Connection timeout in ms (default: 30000)
- `SQLSERVER_REQUEST_TIMEOUT` - Request timeout in ms (default: 60000)
- `SQLSERVER_MAX_ROWS` - Maximum rows per query (default: 1000)
- `SQLSERVER_SESSION_CONTEXT` - JSON object applied to every query as `SESSION_CONTEXT` (see [Row-Level Security](#row-level-security))
- `SQLSERVER_SESSION_CONTEXT_READONLY` - Lock session context keys against modification (default: true)

### Row-Level Security

Databases that use Row-Level Security often scope rows by tenant or user through
`SESSION_CONTEXT()` rather than by login, because the application connects with a
single shared account. Set `SQLSERVER_SESSION_CONTEXT` to a JSON object and every
query runs with that context applied:

```bash
export SQLSERVER_SESSION_CONTEXT='{"TenantId":42,"UserRole":"auditor"}'
```

Values may be strings or numbers; numbers are bound as `int` so predicates
comparing against integer columns work without a `CONVERT`.

The context is set in the same batch as each query. `SESSION_CONTEXT` is
connection-scoped and queries run through a pool, so setting it in a separate
round trip would apply it to an arbitrary connection.

Keys are locked with `@read_only = 1` by default, so a query cannot widen its own
scope even if it were to reach the server with the context already established.
Because read-only keys are released only when a connection resets, changing a
value at runtime requires `SqlServerConnection.resetSessionContext()`, which
recycles the pool. Set `SQLSERVER_SESSION_CONTEXT_READONLY=false` to allow keys
to be overwritten in place instead.

## Usage

Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ ENVIRONMENT VARIABLES:
SQLSERVER_PORT Port number (optional, default: 1433)
SQLSERVER_ENCRYPT Enable encryption (optional, default: true)
SQLSERVER_TRUST_CERT Trust server certificate (optional, default: true)
SQLSERVER_SESSION_CONTEXT JSON object set as SESSION_CONTEXT on every
query, for Row-Level Security (optional)

EXAMPLES:
# Set environment variables and run
Expand Down
74 changes: 73 additions & 1 deletion src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,85 @@ export class SqlServerConnection {
}
}

/**
* Builds the SESSION_CONTEXT preamble for a request.
*
* SESSION_CONTEXT is connection-scoped, and this class runs queries through a
* pool, so the context has to be established in the same batch as the query
* that depends on it -- setting it in a separate round trip would apply it to
* whichever connection the pool happened to hand out at the time.
*
* Keys and values are bound as parameters rather than interpolated, so the
* preamble carries no injection surface. It is built entirely from server
* configuration; no part of it comes from the caller's query.
*
* When read-only keys are in use the EXEC is guarded on SESSION_CONTEXT()
* being unset, because pooled connections are reused: re-running the EXEC
* against an already-locked key raises error 15664.
*/
private applySessionContext(request: sql.Request): string {
const entries = Object.entries(this.config.sessionContext ?? {});
if (entries.length === 0) {
return '';
}

const readOnly = this.config.sessionContextReadOnly !== false;
let preamble = '';

entries.forEach(([key, value], i) => {
const keyParam = `__sc_key_${i}`;
const valueParam = `__sc_val_${i}`;

request.input(keyParam, sql.NVarChar(128), key);
if (typeof value === 'number') {
request.input(valueParam, sql.Int, value);
} else {
request.input(valueParam, sql.NVarChar(sql.MAX), value);
}

const exec =
`EXEC sys.sp_set_session_context @key = @${keyParam}, ` +
`@value = @${valueParam}` +
(readOnly ? ', @read_only = 1' : '') +
';';

preamble += readOnly
? `IF SESSION_CONTEXT(@${keyParam}) IS NULL ${exec}\n`
: `${exec}\n`;
});

return preamble;
}

async query<T = any>(queryText: string): Promise<sql.IResult<T>> {
if (!this.pool) {
throw new Error('Database connection not established');
}

const request = this.pool.request();
return await request.query(queryText);
const preamble = this.applySessionContext(request);
return await request.query(preamble + queryText);
}

/**
* Tears down and reopens the pool, clearing SESSION_CONTEXT on every
* connection.
*
* Read-only keys cannot be cleared from T-SQL -- they are released only when
* the connection resets. tedious can reset an individual connection
* (Connection.reset), but mssql neither calls it nor exposes a per-checkout
* hook, so recycling the pool is the only route through the public API.
* Callers need this to change a session context value that was set read-only.
*/
async resetSessionContext(
sessionContext?: ConnectionConfig['sessionContext']
): Promise<void> {
if (arguments.length > 0) {
this.config = { ...this.config, sessionContext };
}

await this.disconnect();
await this.connect();
}

async testConnection(): Promise<boolean> {
Expand Down
17 changes: 16 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ async function runServer() {
ListToolsRequestSchema,
} = await import('@modelcontextprotocol/sdk/types.js');
const { SqlServerConnection } = await import('./connection.js');
const { ConnectionConfigSchema } = await import('./types.js');
const { ConnectionConfigSchema, parseSessionContext } = await import('./types.js');
const {
ListDatabasesTool,
ListTablesTool,
Expand Down Expand Up @@ -169,6 +169,18 @@ async function runServer() {
return;
}

// Parsed ahead of the config object so a malformed value reports itself
// rather than surfacing as an opaque startup failure.
let sessionContext;
try {
sessionContext = parseSessionContext(process.env.SQLSERVER_SESSION_CONTEXT);
} catch (error) {
console.error(
`Invalid configuration: ${error instanceof Error ? error.message : error}`
);
process.exit(1);
}

// Read configuration from environment variables
const config = {
server: process.env.SQLSERVER_HOST || 'localhost',
Expand All @@ -181,6 +193,9 @@ async function runServer() {
connectionTimeout: parseInt(process.env.SQLSERVER_CONNECTION_TIMEOUT || '30000'),
requestTimeout: parseInt(process.env.SQLSERVER_REQUEST_TIMEOUT || '60000'),
maxRows: parseInt(process.env.SQLSERVER_MAX_ROWS || '1000'),
sessionContext,
sessionContextReadOnly:
process.env.SQLSERVER_SESSION_CONTEXT_READONLY !== 'false',
};

// Validate configuration
Expand Down
36 changes: 36 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,46 @@ export const ConnectionConfigSchema = z.object({
connectionTimeout: z.number().optional().default(30000),
requestTimeout: z.number().optional().default(60000),
maxRows: z.number().optional().default(1000),
sessionContext: z
.record(z.union([z.string(), z.number()]))
.optional(),
sessionContextReadOnly: z.boolean().optional().default(true),
});

export type ConnectionConfig = z.infer<typeof ConnectionConfigSchema>;

/**
* Parses SQLSERVER_SESSION_CONTEXT, a JSON object of key/value pairs applied to
* every query via sp_set_session_context. Used by Row-Level Security predicates
* that scope rows by tenant or user rather than by login.
*/
export function parseSessionContext(
raw: string | undefined
): ConnectionConfig['sessionContext'] {
if (!raw || !raw.trim()) {
return undefined;
}

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(
'SQLSERVER_SESSION_CONTEXT must be a JSON object, e.g. {"TenantId":42}'
);
}

const result = ConnectionConfigSchema.shape.sessionContext.safeParse(parsed);
if (!result.success) {
throw new Error(
'SQLSERVER_SESSION_CONTEXT must be a JSON object whose values are ' +
'strings or numbers, e.g. {"TenantId":42,"UserRole":"auditor"}'
);
}

return result.data;
}

export interface TableInfo {
table_catalog: string;
table_schema: string;
Expand Down