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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ These tools are only listed and callable when write mode is enabled (see [Write
- `get_legal_intakes` — List all legal intakes
- `get_legal_intake_by_id` — Get specific legal intake by id

### Analytics

- `get_analytics_catalog` — List allowlisted analytics tables and column schemas (GoogleSQL); optional `search` to narrow by table or column name/description
- `run_analytics_query` — Execute read-only GoogleSQL against analytics data; call `get_analytics_catalog` first when you need authoritative table/column names, types, or descriptions for this workspace

### Other

- `get_key_pointers` — Retrieves key pointers
Expand Down
20 changes: 20 additions & 0 deletions src/spotdraft_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,20 @@ export class SpotDraftClient {
return `SpotDraft API Error: ${statusText}`;
}

/** Ensures we never silently follow redirects (POST would otherwise become GET). Caller must await before reading the body. */
private async rejectIfRedirect(response: Response, method: string, url: string, endpoint: string): Promise<void> {
if (response.status < 300 || response.status >= 400) {
return;
}
await response.arrayBuffer().catch(() => undefined);
const location = response.headers.get('location');
throw new APIError(
`SpotDraft API returned HTTP ${response.status} redirect (${location ?? 'missing Location header'}). Set baseUrl to the canonical API URL so requests do not redirect (scheme, host, path, trailing slashes).`,
502,
{ endpoint, method, redirectStatus: response.status, location, requestUrl: url },
Comment on lines +130 to +133
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since location/requestUrl are passed verbatim into APIError and MCPError.toJSON() serializes context unchanged, should we omit or redact them from client-facing error context?

Finding type: Basic Security Patterns | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Fix in Cursor

Prompt for AI Agents:

Before applying, verify this suggestion against the current code. In
src/spotdraft_client.ts around lines 124-135 in the rejectIfRedirect method, stop
including redirect target and full request URL in the thrown APIError context. Instead
of passing location and requestUrl verbatim into the context, either omit those fields
entirely or sanitize them to only safe data (e.g., endpoint and API origin without query
string/internal path); ensure MCPError.toJSON (and any shared context serialization)
does not re-emit these sensitive values unchanged. Update the corresponding call sites
for rejectIfRedirect in get (around lines 137-168) and post (around lines 195-205) if
they rely on the removed context fields, and adjust tests/docs accordingly.

);
}

async get(endpoint: string, queryParams?: URLSearchParams) {
const url = queryParams ? `${this.baseUrl}${endpoint}?${queryParams.toString()}` : `${this.baseUrl}${endpoint}`;

Expand All @@ -129,8 +143,11 @@ export class SpotDraftClient {
const response = await fetch(url, {
method: 'GET',
headers: this.headers,
redirect: 'manual',
});

await this.rejectIfRedirect(response, 'GET', url, endpoint);

if (!response.ok) {
const errorBody = await response.text();
this.logger.error('SpotDraftClient', `API request failed`, undefined, {
Expand Down Expand Up @@ -179,8 +196,11 @@ export class SpotDraftClient {
method: 'POST',
headers: this.headers,
body: body ? JSON.stringify(body) : undefined,
redirect: 'manual',
});

await this.rejectIfRedirect(response, 'POST', url, endpoint);

if (!response.ok) {
const errorBody = await response.text();
this.logger.error('SpotDraftClient', `API request failed`, undefined, {
Expand Down
3 changes: 3 additions & 0 deletions src/tools/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@
- Legal Intake:
- `get_legal_intakes`
- `get_legal_intake_by_id`
- Analytics:
- `get_analytics_catalog`
- `run_analytics_query`

## Registry Invariants

Expand Down
16 changes: 16 additions & 0 deletions src/tools/analytics/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# AGENTS.md

## Read This When

- You are editing the analytics catalog tool, analytics query tool, embedded catalog summary, or changing the upstream analytics public API contracts.

## Public Surface

- `get_analytics_catalog.ts` — MCP tool and handler; `GET` `/v2.1/public/analytics/catalog/` with optional query `search`.
- `run_analytics_query.ts` — MCP tool and handler; forwards to SpotDraft with optional `user_scoped`.
- `analytics_query_docs.ts` — `buildRunAnalyticsQueryDescription()`: embedded OpenAPI-aligned schema summary (allowlisted tables, limits, errors), plus MCP-only guidance to call `get_analytics_catalog` for workspace schema discovery. **Keep the upstream-oriented paragraphs in sync** with `paths['/api/v2.1/public/analytics/query/'].post.description` in the regional `https://<host>/api/docs/schema/` when the catalog copy changes.

## Upstream

- `GET` `/v2.1/public/analytics/catalog/` — tables and columns for analytics SQL; optional `search` query parameter (see `paths['/api/v2.1/public/analytics/catalog/'].get.description` in regional schema).
- `POST` `/v2.1/public/analytics/query/` — read-only GoogleSQL; request body `query` (required), `user_scoped` (optional).
67 changes: 67 additions & 0 deletions src/tools/analytics/analytics_query_docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Embedded summary for `run_analytics_query` tool descriptions.
* Keep in sync with `paths['/api/v2.1/public/analytics/query/'].post.description`
* in https://api.in.spotdraft.com/api/docs/schema/ when the deployment catalog changes.
*/

const ANALYTICS_TABLE_CATALOG_URL = 'https://api.in.spotdraft.com/api/docs#description/analytics-query-table-catalog';
const ANALYTICS_TAG_URL = 'https://api.in.spotdraft.com/api/docs#tag/v21-analytics-query';

/** Upstream OpenAPI operation description (Scalar-specific anchor link replaced with a public URL). */
const ANALYTICS_QUERY_OPERATION_SUMMARY = `Runs **read-only** analytics SQL in **GoogleSQL** (Google Standard SQL), including for timestamps, dates, and functions. Submit exactly one **\`SELECT\`** statement (optional **\`WITH\`** / **\`UNION\`**). Inserts, updates, deletes, DDL, and multi-statement batches are rejected.

**Enablement**
This capability must be enabled for your workspace. If it is not, the API returns **501** with code \`SD_PUBLIC_00045\`. Contact SpotDraft support to request access.

**Permission**
**Insights view** (\`INSIGHTS_VIEW\`).

**Contracts scoping**
By default, the **\`Contracts\`** table is limited to rows the authenticated user may access. Optional body field **\`user_scoped\`** or header **\`X-Spotdraft-Analytics-User-Scoped\`** (\`true\` / \`false\`, or \`1\` / \`0\`, \`yes\` / \`no\`) overrides that. When both are omitted, the default is **\`true\`** in this environment. **\`user_scoped: false\`** (or the header equivalent) allows workspace-wide contract rows only for users with the workspace **Admin** role; others receive **403** with \`SD_PUBLIC_00044\`.

**Tables and columns**
Use **bare catalog table names** (e.g. \`Contracts\`, \`Users\`) and **snake_case** public column names from the table catalog (e.g. \`execution_time\`, \`workflow_id\`). Only allowlisted tables and columns are accepted. For this workspace’s live schema (and to avoid \`SD_PUBLIC_00041\` on bad table/column names), call the **\`get_analytics_catalog\`** MCP tool first; use optional \`search\` to filter tables or columns.

**Allowlisted tables (quick reference)**

| Table in SQL | Role |
|---|---|
| \`Contracts\` | Core contract fact table |
| \`Users\` | Users / org users |
| \`Workflows_setup\` | Workflow definitions (incl. frozen versions) |
| \`Contract_types_setup\` | Contract type lookup |
| \`Counterparties_setup\` | Counterparty directory |
| \`Approvals_Setup\` | Approval steps in workflows |
| \`Entities_setup\` | Legal entities |
| \`Signatures_setups\` | Signatory configuration per workflow |

For full column lists, types, and join guidance, prefer **\`get_analytics_catalog\`**; the [SpotDraft public analytics table catalog](${ANALYTICS_TABLE_CATALOG_URL}) is supplementary documentation.

**Limits (this deployment)**
- At most **5,000** rows per response; \`truncated\` is **true** when more rows would have matched.
- Query job timeout: **60 seconds**.
- Maximum bytes billed per query job: **500 MB** (500,000,000 bytes).
- SQL text length limit: **10,000** characters per request.

**Errors**

| Code | HTTP | Meaning |
|---|---:|---|
| \`SD_PUBLIC_00040\` | 400 | Not an allowed read-only SELECT, or more than one statement |
| \`SD_PUBLIC_00041\` | 400 | References a table outside the allowlisted catalog |
| \`SD_PUBLIC_00042\` | 400 | Query could not be parsed or transpiled safely |
| \`SD_PUBLIC_00043\` | 502 | Query execution failed |
| \`SD_PUBLIC_00044\` | 403 | Workspace-wide contracts requested without workspace Admin role |
| \`SD_PUBLIC_00045\` | 501 | Analytics query API not enabled for this workspace |

[V2.1 Analytics Query API reference](${ANALYTICS_TAG_URL})`;

export function buildRunAnalyticsQueryDescription(): string {
return `Execute read-only SpotDraft analytics SQL (GoogleSQL).

**When to use:** Custom reporting, metrics, or joins across allowlisted analytics tables when contracts workspace analytics is enabled for your org.

**Before writing SQL:** Call **\`get_analytics_catalog\`** when you need accurate table and column names, types, or descriptions for this workspace, or when exploring what is queryable. Use it before \`run_analytics_query\` whenever the schema is unclear.

${ANALYTICS_QUERY_OPERATION_SUMMARY}`;
}
33 changes: 33 additions & 0 deletions src/tools/analytics/get_analytics_catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { SpotDraftClient } from '../../spotdraft_client.js';

const getAnalyticsCatalogTool: Tool = {
name: 'get_analytics_catalog',
description: `Returns allowlisted analytics tables and column schemas for GoogleSQL used with run_analytics_query. Requires Insights view (INSIGHTS_VIEW). Optional search narrows results (case-insensitive match on table/column names and descriptions). Analytics query API must be enabled for the workspace or upstream returns 501.`,
inputSchema: {
type: 'object',
properties: {
search: {
type: 'string',
description:
'Optional filter: case-insensitive substring match on table names, table descriptions, column names, and column descriptions.',
},
},
},
};

export interface GetAnalyticsCatalogRequest {
search?: string;
}

const getAnalyticsCatalog = async (
request: GetAnalyticsCatalogRequest,
spotdraftClient: SpotDraftClient,
): Promise<unknown> => {
const endpoint = '/v2.1/public/analytics/catalog/';
const search = request.search?.trim();
const queryParams = search !== undefined && search !== '' ? new URLSearchParams({ search }) : undefined;
return spotdraftClient.get(endpoint, queryParams);
Comment on lines +23 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

handleCallToolRequest() reads request.search even though request.params.arguments can be omitted per MCP, so empty calls can throw before tools can return the no-search catalog—can we default request to {} (or in the dispatcher) before accessing search?

Finding type: Logical Bugs | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Fix in Cursor

Prompt for AI Agents:

Before applying, verify this suggestion against the current code. In
src/tools/analytics/get_analytics_catalog.ts around lines 23-30, in the
`getAnalyticsCatalog` function, make the `request` parameter resilient to being omitted
by defaulting it to an empty object (e.g., treat `undefined` as `{}`) before reading
`request.search`. This prevents dereferencing/reading `search` when the MCP `tools/call`
omits `arguments` and ensures the tool returns the full catalog when no search is
provided. Apply the normalization at the entry point of `getAnalyticsCatalog` (or
equivalently in the dispatcher that calls it) and keep the existing trim/empty-string
behavior for `search`.

};

export { getAnalyticsCatalog, getAnalyticsCatalogTool };
57 changes: 57 additions & 0 deletions src/tools/analytics/run_analytics_query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { ValidationError } from '../../errors/index.js';
import { SpotDraftClient } from '../../spotdraft_client.js';
import { buildRunAnalyticsQueryDescription } from './analytics_query_docs.js';

const MAX_QUERY_LENGTH = 10000;

const runAnalyticsQueryTool: Tool = {
name: 'run_analytics_query',
description: buildRunAnalyticsQueryDescription(),
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description:
'A single GoogleSQL SELECT (optional WITH / UNION). Max 10,000 characters. Only allowlisted tables/columns; see tool description.',
},
user_scoped: {
type: 'boolean',
description:
'When true (default when omitted), restrict Contracts to rows the caller may access. When false, workspace-wide Contracts only for workspace Admin; others get 403.',
},
},
required: ['query'],
},
};

export interface RunAnalyticsQueryRequest {
query: string;
user_scoped?: boolean;
}

const runAnalyticsQuery = async (
request: RunAnalyticsQueryRequest,
spotdraftClient: SpotDraftClient,
): Promise<unknown> => {
const query = request.query?.trim() ?? '';
if (query === '') {
throw new ValidationError('query is required and cannot be empty.', { field: 'query' });
Comment on lines +34 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

request isn’t defaulted, so if MCP arguments is omitted the handler can pass undefined and request.query?.trim() throws before ValidationError('query is required...') can run—should we normalize to {} first?

Finding type: Logical Bugs | Severity: 🟠 Medium


Want Baz to fix this for you? Activate Fixer

Fix in Cursor

Prompt for AI Agents:

Before applying, verify this suggestion against the current code. In
src/tools/analytics/run_analytics_query.ts around lines 34-40 in the runAnalyticsQuery
function, the code uses request.query?.trim() but assumes request is always defined; if
the dispatcher passes undefined, this can throw a TypeError before the existing
ValidationError logic runs. Refactor the function signature or first line to normalize
request to an empty object when it’s undefined (e.g., default request = {}). Then
compute and validate the trimmed query from that normalized object so the “query is
required and cannot be empty” ValidationError is consistently returned for omitted or
empty inputs.

}
if (query.length > MAX_QUERY_LENGTH) {
throw new ValidationError(`query exceeds maximum length of ${MAX_QUERY_LENGTH} characters.`, {
field: 'query',
length: query.length,
});
}

const body: { query: string; user_scoped?: boolean } = { query };
if (typeof request.user_scoped === 'boolean') {
body.user_scoped = request.user_scoped;
}

return spotdraftClient.post('/v2.1/public/analytics/query', body);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Call analytics query endpoint with trailing slash

run_analytics_query posts to /v2.1/public/analytics/query (no trailing slash), but this tool’s own upstream contract documents the route as /v2.1/public/analytics/query/. In this same commit, SpotDraftClient.post() was changed to redirect: 'manual' and now throws on any 3xx via rejectIfRedirect, so environments that canonicalize the missing-slash URL with a 301/308 will now fail every analytics query with a 502 instead of executing it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

run_analytics_query calls /v2.1/public/analytics/query but the contract expects POST /v2.1/public/analytics/query/, and with SpotDraftClient rejecting 3xx redirects this should be updated to the slash-suffixed path.

Finding type: Breaking Changes | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Fix in Cursor

Prompt for AI Agents:

Before applying, verify this suggestion against the current code. In
`src/tools/analytics/run_analytics_query.ts` around lines 54-54 inside the
`runAnalyticsQuery` function, update the `spotdraftClient.post` path from
`'/v2.1/public/analytics/query'` to the canonical slash-suffixed endpoint
`'/v2.1/public/analytics/query/'` as specified by the analytics contract/docs. This
prevents a 3xx redirect that now gets rejected (causing a 502) due to `SpotDraftClient`
using `redirect: 'manual'`. After changing the path, quickly scan the repo for any other
calls to the no-slash analytics query endpoint and align them to the slash-suffixed
version.

};

export { runAnalyticsQuery, runAnalyticsQueryTool };
14 changes: 14 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ import { getTemplateDetails, getTemplateDetailsTool } from './templates/get_temp
import { getTemplateMetadata, getTemplateMetadataTool } from './templates/get_template_metadata.js';
import { getTemplates, getTemplatesTool } from './templates/get_templates.js';

// Analytics tools
import { getAnalyticsCatalog, getAnalyticsCatalogTool } from './analytics/get_analytics_catalog.js';
import { runAnalyticsQuery, runAnalyticsQueryTool } from './analytics/run_analytics_query.js';

/**
* Global tool registry instance
* All tools are registered here for centralized management
Expand Down Expand Up @@ -159,6 +163,16 @@ toolRegistry.register({
handler: withSpotDraftClient(getLegalIntakeById),
});

toolRegistry.register({
tool: getAnalyticsCatalogTool,
handler: withSpotDraftClient(getAnalyticsCatalog),
});

toolRegistry.register({
tool: runAnalyticsQueryTool,
handler: withSpotDraftClient(runAnalyticsQuery),
});

// Write tools (only available when write mode is enabled)
toolRegistry.register({
tool: uploadContractVersionTool,
Expand Down