Skip to content
Merged
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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|---|
Expand All @@ -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" <commit> && 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 <package>@<version>`) 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/)
Expand Down
6 changes: 4 additions & 2 deletions mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
92 changes: 82 additions & 10 deletions mcp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,21 @@ const CONNECTION_PROP: Record<string, object> = 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<string, object> = {
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 ? [{
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -718,6 +737,18 @@ function validateToolArgs(name: string, args: Record<string, unknown>): 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;
}

Expand All @@ -734,10 +765,51 @@ function handleListConnections(): string {
);
}

async function handleGetCommandCatalogue(conn: BestConnection): Promise<string> {
/** 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<string, unknown>;
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, unknown>): 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<string, unknown>, conn: BestConnection): Promise<string> {
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<string, unknown>, conn: BestConnection): Promise<string> {
Expand Down Expand Up @@ -803,10 +875,10 @@ async function handleSendCommandAndWait(args: Record<string, unknown>, conn: Bes
return `${commandResult}\n\nWarning: timed out after ${timeoutSeconds}s waiting for '${pollUntilContains ?? 'any result'}' in ${pollQuery}.`;
}

async function handleGetQueryCatalogue(conn: BestConnection): Promise<string> {
async function handleGetQueryCatalogue(args: Record<string, unknown>, conn: BestConnection): Promise<string> {
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<string, unknown>, conn: BestConnection): Promise<string> {
Expand Down Expand Up @@ -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;
Expand Down
Loading