From c29ff0f616c73358b394cf96015a492361b53c1a Mon Sep 17 00:00:00 2001 From: riccardone Date: Tue, 4 Aug 2026 09:49:53 +0100 Subject: [PATCH] Summarise catalogue listings by default, and document the legacy 1.x release path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated papercuts, both surfaced while shipping the argument-validation fix. ── Catalogue size ── get_command_catalogue returned every command's full description. On a thoroughly documented endpoint that is 47 KB for ~65 commands, which clients spill to a file before a model can read it — for a listing whose only job is to let the caller CHOOSE an operation. Both catalogue tools now truncate each description to 200 characters unless the caller passes detail: "full". Measured against a fixture matching that shape: 59.9 KB → 23.6 KB, 61% smaller. Nothing is lost — get_command_schema / get_query_schema already return one operation's complete text. A character cap rather than "first sentence" because real descriptions OPEN with routing metadata ("Source: managing. Schema: approve-absence/1.0. Approves a pending or overdue absence…"), so keeping the first sentence would discard the part that says what the operation does. The cut lands on a word boundary when one is close. Short descriptions are untouched, non-description fields are untouched, and the output stays a bare JSON array in both modes so the shape is unchanged. Also extends validateToolArgs to reject values outside a declared enum. `detail` is the first enum on this surface, and a typo'd value silently falling through to the handler default is precisely the silent-wrong-behaviour that function exists to stop. ── Release docs ── Nothing recorded that the pre-rename @behavioralstate/bsp-mcp 1.x line still exists and is maintained off the mcp/v1.7.1 tag, or that release-mcp.sh cannot cut a 1.x release. It requires main, where package.json is @behavioralstate/best-mcp — so tagging mcp/v1.x from main would publish best-mcp at that version and, since CI's npm publish passes no --tag, move best-mcp's latest BACKWARDS. Documents the manual-tag exception, states that the tag prefix does not select the package (CI publishes whatever the tagged commit names), and notes verifying a release by packing the published tarball, since dist/ is gitignored yet ships. Also corrects "two independent artifacts" — the table lists three. Verified by driving the built stdio server with raw JSON-RPC against a local fake endpoint: 8 checks covering the size reduction, both catalogues, the cap, untouched short descriptions, surviving fields, verbatim full mode, and the rejected enum value. The earlier argument-validation cases still pass. No version bump — scripts/release-mcp.sh does that when cutting the release. Co-Authored-By: Claude Opus 5 --- README.md | 16 ++++++- mcp-server/README.md | 6 ++- mcp-server/src/index.ts | 92 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6abcaa4..b47dd86 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Full option reference: [validate-cli/README.md](validate-cli/README.md). The `-- ## Cutting a Release -This repo has **two independent** versioned artifacts. Running one release does not release the other. Always use the scripts — never tag manually. +This repo has **three independent** versioned artifacts. Running one release does not release the others. Always use the scripts — never tag manually (the one exception is the legacy 1.x MCP line, below). | Artifact | Tag prefix | Command | Outcome | |---|---|---|---| @@ -103,6 +103,20 @@ This repo has **two independent** versioned artifacts. Running one release does `release-mcp.sh` with no argument auto-bumps the patch version. `release.sh` requires a clean `main` checkout, bumps `version.json` (the single source of truth for `{{BEST_VERSION}}` placeholders), updates this README's release references, tags, and creates the GitHub Release. When releasing both in one session, release `best-mcp` first. +**The tag prefix does not select the npm package.** CI checks out the tagged commit and publishes whatever `mcp-server/package.json` names *there*, so package identity comes from the commit, not the tag. + +### Legacy `@behavioralstate/bsp-mcp` (1.x) + +The MCP server was published as `@behavioralstate/bsp-mcp` before the BSP → BEST rename. Deployments still pinned to that package are maintained on a branch cut from the `mcp/v1.7.1` tag and released by tagging **manually** — the one exception to "always use the scripts": + +```bash +git tag -a mcp/v1.7.2 -m "Release mcp/v1.7.2" && git push origin mcp/v1.7.2 +``` + +`release-mcp.sh` cannot cut a 1.x release, and **must not be used to try**. It requires `main`, where `mcp-server/package.json` is `@behavioralstate/best-mcp` — so tagging `mcp/v1.x` from `main` would publish **best-mcp** at that version, and because CI's `npm publish` passes no `--tag`, that moves best-mcp's `latest` *backwards*. + +Fixes that apply to both lines get released on both. Verify either release by packing the published tarball (`npm pack @`) and checking `package/dist/index.js` — `dist/` is gitignored yet ships, so the artifact depends on a build having run. + ## Community - [Website & Documentation](https://behavioralstate.io/) diff --git a/mcp-server/README.md b/mcp-server/README.md index 244572a..d0b5a1c 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -55,16 +55,18 @@ Spec reference: https://behavioralstate.io/docs | Tool | What it does | |---|---| | `list_connections` | List all configured connections with names, endpoints, and descriptions *(only shown when multiple connections are configured)* | -| `get_command_catalogue` | List all commands this endpoint accepts | +| `get_command_catalogue` | List all commands this endpoint accepts (descriptions truncated; `detail: "full"` for verbatim) | | `get_command_schema` | Fetch the full JSON Schema for a command type — learn the exact fields required | | `send_command` | Send a command (CloudEvent 1.0 envelope built automatically) | | `send_command_and_wait` | Send a command then poll a query until a condition is met | -| `get_query_catalogue` | List all read queries this endpoint exposes | +| `get_query_catalogue` | List all read queries this endpoint exposes (descriptions truncated; `detail: "full"` for verbatim) | | `get_query_schema` | Fetch the JSON Schema for a query — learn parameters and response shape | | `execute_query` | Execute a query and return current state synchronously | | `get_workflows` | List the service's published "descriptive sequence" recipes — an optional vendor extension; returns a note if the service publishes none | Intended LLM flow: `get_command_catalogue` → pick a command → `get_command_schema` → gather fields → `send_command`. + +Both catalogue tools truncate each entry's description by default, because a catalogue exists to let a caller *choose* an operation and a thoroughly documented service makes the full listing too large for that — one endpoint returns 47 KB for ~65 commands, which clients spill to disk before a model can read it. Truncation is ~60% smaller and still enough to pick from; the schema tools return one operation's complete text, and `detail: "full"` returns every description verbatim when you really need to compare across entries. Optionally call `get_workflows` first to see if the service publishes a ready-made recipe for a multi-step process. When multiple connections are configured all operation tools gain an optional `connection` parameter. If the LLM is not certain which connection the user intends, it calls `list_connections` and asks the user to confirm before proceeding. diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 6b94a1e..a0540da 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -451,6 +451,21 @@ const CONNECTION_PROP: Record = MULTI ? { } } : {}; +// Shared by both catalogue tools. Summary is the default because a catalogue's job is to let a caller +// CHOOSE an operation, and a thoroughly documented service makes the full listing too large for that — +// one real endpoint returns 47 KB for ~65 commands, which clients spill to disk before a model reads it. +const CATALOGUE_DETAIL_PROP: Record = { + detail: { + type: 'string', + enum: ['summary', 'full'], + description: + "How much description text to return per entry. 'summary' (the default) truncates each description " + + "to keep the listing small — enough to pick an operation. 'full' returns every description verbatim; " + + 'only worth it when you genuinely need to compare long descriptions across many entries, since the ' + + "per-operation schema tools already return one operation's complete text." + } +}; + const TOOLS: Tool[] = [ // list_connections is only meaningful (and only shown) when MULTI is true ...(MULTI ? [{ @@ -467,8 +482,10 @@ const TOOLS: Tool[] = [ 'List all commands this BEST endpoint accepts. ' + 'Returns the command catalogue: every command type with its schema name, version, dataschema URI, and description. ' + 'Call this first to discover what you can send. ' + - 'Examples: configure-broker, configure-indicator-alert, submit-signal, archive-broker.', - inputSchema: { type: 'object', properties: { ...CONNECTION_PROP }, required: [] } + 'Examples: configure-broker, configure-indicator-alert, submit-signal, archive-broker. ' + + "Descriptions are truncated by default so the listing stays small — call get_command_schema for one " + + "command's complete description and fields, or pass detail='full' to get every description verbatim.", + inputSchema: { type: 'object', properties: { ...CONNECTION_PROP, ...CATALOGUE_DETAIL_PROP }, required: [] } }, { name: 'get_command_schema', @@ -592,8 +609,10 @@ const TOOLS: Tool[] = [ 'List all read queries available at this BEST endpoint. ' + 'Returns the query catalogue: every query type with its schema name, version, dataschema URI, and description. ' + 'Call this to discover what current-state data you can read. ' + - 'Examples: list-brokers (get configured broker accounts), list-alerts (get configured alerts), list-price-feeds (get configured price feeds).', - inputSchema: { type: 'object', properties: { ...CONNECTION_PROP }, required: [] } + 'Examples: list-brokers (get configured broker accounts), list-alerts (get configured alerts), list-price-feeds (get configured price feeds). ' + + "Descriptions are truncated by default so the listing stays small — call get_query_schema for one " + + "query's complete description and parameters, or pass detail='full' to get every description verbatim.", + inputSchema: { type: 'object', properties: { ...CONNECTION_PROP, ...CATALOGUE_DETAIL_PROP }, required: [] } }, { name: 'get_query_schema', @@ -718,6 +737,18 @@ function validateToolArgs(name: string, args: Record): string | `Accepted: ${accepted.join(', ')}. Nothing was sent to the endpoint.`; } + // A value outside a declared enum would otherwise fall through to whatever the handler's default + // happens to be — the same silent-wrong-behaviour this function exists to stop. + for (const [key, spec] of Object.entries(schema.properties ?? {})) { + const allowed = (spec as { enum?: unknown[] }).enum; + if (!Array.isArray(allowed)) continue; + const value = args[key]; + if (value !== undefined && !allowed.includes(value)) { + return `Invalid value for '${key}' on ${name}: ${JSON.stringify(value)}. ` + + `Allowed: ${allowed.map(v => JSON.stringify(v)).join(', ')}. Nothing was sent to the endpoint.`; + } + } + return null; } @@ -734,10 +765,51 @@ function handleListConnections(): string { ); } -async function handleGetCommandCatalogue(conn: BestConnection): Promise { +/** Longest description kept per entry in a summary catalogue listing. */ +const SUMMARY_DESCRIPTION_CHARS = 200; + +/** + * Truncates one catalogue entry's description for summary mode, leaving every other field alone. + * + * A character cap rather than "first sentence": service descriptions routinely OPEN with routing + * metadata (e.g. "Source: managing. Schema: cancel-absence/1.0. Cancels an existing absence…"), so + * keeping the first sentence would throw away the part that says what the operation does. The cut + * lands on a word boundary when one is close enough, so the tail is not a half-word. + */ +function summariseCatalogueEntry(entry: unknown): unknown { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return entry; + + const fields = entry as Record; + const description = fields.description; + if (typeof description !== 'string' || description.length <= SUMMARY_DESCRIPTION_CHARS) return entry; + + const clipped = description.slice(0, SUMMARY_DESCRIPTION_CHARS); + const lastSpace = clipped.lastIndexOf(' '); + const kept = (lastSpace > SUMMARY_DESCRIPTION_CHARS * 0.6 ? clipped.slice(0, lastSpace) : clipped).trimEnd(); + return { ...fields, description: `${kept}…` }; +} + +/** + * Renders a catalogue as JSON, truncating descriptions unless the caller asked for 'full'. + * + * Catalogues exist to let a caller CHOOSE an operation, and a service that documents each one + * thoroughly makes the full listing far too large for that job — one real endpoint returns 47 KB for + * ~65 commands, which clients spill to disk before a model can read it. The per-operation schema tools + * already return the complete description, so the long text is never lost, only deferred. + * + * Output stays a bare JSON array in both modes: the shape is unchanged, and the affordance is + * advertised in the tool description where the model actually reads it. + */ +function renderCatalogue(entries: unknown[], args: Record): string { + const detail = typeof args.detail === 'string' ? args.detail.toLowerCase() : 'summary'; + const rendered = detail === 'full' ? entries : entries.map(summariseCatalogueEntry); + return JSON.stringify(rendered, null, 2); +} + +async function handleGetCommandCatalogue(args: Record, conn: BestConnection): Promise { const data = await bestGet<{ commands: unknown[] }>('/commands', conn); if (!data.commands.length) return 'No commands available at this endpoint.'; - return JSON.stringify(data.commands, null, 2); + return renderCatalogue(data.commands, args); } async function handleGetCommandSchema(args: Record, conn: BestConnection): Promise { @@ -803,10 +875,10 @@ async function handleSendCommandAndWait(args: Record, conn: Bes return `${commandResult}\n\nWarning: timed out after ${timeoutSeconds}s waiting for '${pollUntilContains ?? 'any result'}' in ${pollQuery}.`; } -async function handleGetQueryCatalogue(conn: BestConnection): Promise { +async function handleGetQueryCatalogue(args: Record, conn: BestConnection): Promise { const data = await bestGet<{ queries: unknown[] }>('/queries', conn); if (!data.queries.length) return 'No queries available at this endpoint.'; - return JSON.stringify(data.queries, null, 2); + return renderCatalogue(data.queries, args); } async function handleGetQuerySchema(args: Record, conn: BestConnection): Promise { @@ -936,11 +1008,11 @@ function createMcpServer(requestHeaders?: IncomingHttpHeaders): Server { let text: string; switch (name) { - case 'get_command_catalogue': text = await handleGetCommandCatalogue(conn); break; + case 'get_command_catalogue': text = await handleGetCommandCatalogue(safeArgs, conn); break; case 'get_command_schema': text = await handleGetCommandSchema(safeArgs, conn); break; case 'send_command': text = await handleSendCommand(safeArgs, conn); break; case 'send_command_and_wait': text = await handleSendCommandAndWait(safeArgs, conn); break; - case 'get_query_catalogue': text = await handleGetQueryCatalogue(conn); break; + case 'get_query_catalogue': text = await handleGetQueryCatalogue(safeArgs, conn); break; case 'get_query_schema': text = await handleGetQuerySchema(safeArgs, conn); break; case 'execute_query': text = await handleExecuteQuery(safeArgs, conn); break; case 'get_workflows': text = await handleGetWorkflows(conn); break;