From 5cb1499f5651f1fd65ff80c6ed9f231c160b0a62 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:08:25 +0000 Subject: [PATCH 1/9] fix(mcp): pass base url to code tool --- packages/mcp-server/src/code-tool.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mcp-server/src/code-tool.ts b/packages/mcp-server/src/code-tool.ts index bd17292..a1c92ad 100644 --- a/packages/mcp-server/src/code-tool.ts +++ b/packages/mcp-server/src/code-tool.ts @@ -38,6 +38,7 @@ export async function codeTool() { client_envs: JSON.stringify({ HYPERSPELL_API_KEY: readEnv('HYPERSPELL_API_KEY'), HYPERSPELL_USER_ID: readEnv('HYPERSPELL_USER_ID'), + HYPERSPELL_BASE_URL: readEnv('HYPERSPELL_BASE_URL'), }), }, body: JSON.stringify({ From 5aa93e0c5b58a2023376fe6e82a91982c122fd91 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:22:24 +0000 Subject: [PATCH 2/9] chore(mcp)!: remove deprecated tool schemes This removes all tool schemes except for "code mode" tools. Specifically, this removes "all tools" and "dynamic tools" schemes. Additionally, this removes support for resource filtering, tags, jq filtering, and compatibility controls in MCP servers, as they are no longer necessary when using code mode. # Migration To migrate, simply modify the command used to invoke the MCP server. Currently, the only supported tool scheme is code mode. Now, starting the server with just `node /path/to/mcp/server` or `npx package-name` will invoke code tools: changing your command to one of these is likely all you will need to do. Specifically, you must remove all flags including things like --resources, --tags, --client, --tools=dynamic, etc from your invocation command. The only supported flags are now `--port`, `--transport`, `--socket`, and `--tools=docs` (specifically for "docs"). These are also the only options available for http servers. migration-effort: small --- packages/mcp-server/Dockerfile | 7 - packages/mcp-server/README.md | 187 +-- packages/mcp-server/src/code-tool.ts | 4 +- packages/mcp-server/src/compat.ts | 483 ------- packages/mcp-server/src/docs-search-tool.ts | 2 +- packages/mcp-server/src/dynamic-tools.ts | 159 --- packages/mcp-server/src/filtering.ts | 18 - packages/mcp-server/src/http.ts | 20 +- packages/mcp-server/src/index.ts | 57 +- packages/mcp-server/src/options.ts | 391 +----- packages/mcp-server/src/server.ts | 82 +- packages/mcp-server/src/stdio.ts | 5 +- packages/mcp-server/src/tools.ts | 1 - .../mcp-server/src/tools/auth/user-info.ts | 51 - .../src/tools/connections/list-connections.ts | 51 - packages/mcp-server/src/tools/index.ts | 87 -- .../tools/integrations/connect-integration.ts | 61 - .../tools/integrations/list-integrations.ts | 51 - .../src/tools/memories/add-memory.ts | 84 -- .../src/tools/memories/get-memory.ts | 107 -- .../mcp-server/src/tools/memories/search.ts | 520 -------- .../src/tools/memories/update-memory.ts | 159 --- .../src/tools/memories/upload-file.ts | 65 - packages/mcp-server/src/{tools => }/types.ts | 2 +- packages/mcp-server/tests/compat.test.ts | 1166 ----------------- .../mcp-server/tests/dynamic-tools.test.ts | 185 --- packages/mcp-server/tests/options.test.ts | 510 +------ packages/mcp-server/tests/tools.test.ts | 225 ---- 28 files changed, 51 insertions(+), 4689 deletions(-) delete mode 100644 packages/mcp-server/src/compat.ts delete mode 100644 packages/mcp-server/src/dynamic-tools.ts delete mode 100644 packages/mcp-server/src/filtering.ts delete mode 100644 packages/mcp-server/src/tools.ts delete mode 100644 packages/mcp-server/src/tools/auth/user-info.ts delete mode 100644 packages/mcp-server/src/tools/connections/list-connections.ts delete mode 100644 packages/mcp-server/src/tools/index.ts delete mode 100644 packages/mcp-server/src/tools/integrations/connect-integration.ts delete mode 100644 packages/mcp-server/src/tools/integrations/list-integrations.ts delete mode 100644 packages/mcp-server/src/tools/memories/add-memory.ts delete mode 100644 packages/mcp-server/src/tools/memories/get-memory.ts delete mode 100644 packages/mcp-server/src/tools/memories/search.ts delete mode 100644 packages/mcp-server/src/tools/memories/update-memory.ts delete mode 100644 packages/mcp-server/src/tools/memories/upload-file.ts rename packages/mcp-server/src/{tools => }/types.ts (98%) delete mode 100644 packages/mcp-server/tests/compat.test.ts delete mode 100644 packages/mcp-server/tests/dynamic-tools.test.ts delete mode 100644 packages/mcp-server/tests/tools.test.ts diff --git a/packages/mcp-server/Dockerfile b/packages/mcp-server/Dockerfile index e9af9ae..4cbb501 100644 --- a/packages/mcp-server/Dockerfile +++ b/packages/mcp-server/Dockerfile @@ -39,9 +39,6 @@ # Production stage - FROM denoland/deno:alpine - RUN apk add --no-cache npm - # Add non-root user RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 @@ -66,10 +63,6 @@ COPY --from=builder /build/packages/mcp-server/node_modules ./node_modules # The MCP server uses stdio transport by default # No exposed ports needed for stdio communication - # This is needed for node to run on the deno:alpine image. - # See . - ENV LD_LIBRARY_PATH=/usr/lib:/usr/local/lib - # Set the entrypoint to the MCP server ENTRYPOINT ["node", "index.js"] diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 48db197..47f1412 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -26,7 +26,7 @@ For clients with a configuration JSON, it might look something like this: "mcpServers": { "hyperspell_api": { "command": "npx", - "args": ["-y", "hyperspell-mcp", "--client=claude", "--tools=all"], + "args": ["-y", "hyperspell-mcp"], "env": { "HYPERSPELL_API_KEY": "My API Key", "HYPERSPELL_USER_ID": "My User ID" @@ -59,110 +59,22 @@ environment variables in Claude Code's `.claude.json`, which can be found in you claude mcp add --transport stdio hyperspell_api --env HYPERSPELL_API_KEY="Your HYPERSPELL_API_KEY here." HYPERSPELL_USER_ID="Your HYPERSPELL_USER_ID here." -- npx -y hyperspell-mcp ``` -## Exposing endpoints to your MCP Client +## Code Mode -There are three ways to expose endpoints as tools in the MCP server: +This MCP server is built on the "Code Mode" tool scheme. In this MCP Server, +your agent will write code against the TypeScript SDK, which will then be executed in an +isolated sandbox. To accomplish this, the server will expose two tools to your agent: -1. Exposing one tool per endpoint, and filtering as necessary -2. Exposing a set of tools to dynamically discover and invoke endpoints from the API -3. Exposing a docs search tool and a code execution tool, allowing the client to write code to be executed against the TypeScript client +- The first tool is a docs search tool, which can be used to generically query for + documentation about your API/SDK. -### Filtering endpoints and tools +- The second tool is a code tool, where the agent can write code against the TypeScript SDK. + The code will be executed in a sandbox environment without web or filesystem access. Then, + anything the code returns or prints will be returned to the agent as the result of the + tool call. -You can run the package on the command line to discover and filter the set of tools that are exposed by the -MCP Server. This can be helpful for large APIs where including all endpoints at once is too much for your AI's -context window. - -You can filter by multiple aspects: - -- `--tool` includes a specific tool by name -- `--resource` includes all tools under a specific resource, and can have wildcards, e.g. `my.resource*` -- `--operation` includes just read (get/list) or just write operations - -### Dynamic tools - -If you specify `--tools=dynamic` to the MCP server, instead of exposing one tool per endpoint in the API, it will -expose the following tools: - -1. `list_api_endpoints` - Discovers available endpoints, with optional filtering by search query -2. `get_api_endpoint_schema` - Gets detailed schema information for a specific endpoint -3. `invoke_api_endpoint` - Executes any endpoint with the appropriate parameters - -This allows you to have the full set of API endpoints available to your MCP Client, while not requiring that all -of their schemas be loaded into context at once. Instead, the LLM will automatically use these tools together to -search for, look up, and invoke endpoints dynamically. However, due to the indirect nature of the schemas, it -can struggle to provide the correct properties a bit more than when tools are imported explicitly. Therefore, -you can opt-in to explicit tools, the dynamic tools, or both. - -See more information with `--help`. - -All of these command-line options can be repeated, combined together, and have corresponding exclusion versions (e.g. `--no-tool`). - -Use `--list` to see the list of available tools, or see below. - -### Code execution - -If you specify `--tools=code` to the MCP server, it will expose just two tools: - -- `search_docs` - Searches the API documentation and returns a list of markdown results -- `execute` - Runs code against the TypeScript client - -This allows the LLM to implement more complex logic by chaining together many API calls without loading -intermediary results into its context window. - -The code execution itself happens in a Deno sandbox that has network access only to the base URL for the API. - -### Specifying the MCP Client - -Different clients have varying abilities to handle arbitrary tools and schemas. - -You can specify the client you are using with the `--client` argument, and the MCP server will automatically -serve tools and schemas that are more compatible with that client. - -- `--client=`: Set all capabilities based on a known MCP client - - - Valid values: `openai-agents`, `claude`, `claude-code`, `cursor` - - Example: `--client=cursor` - -Additionally, if you have a client not on the above list, or the client has gotten better -over time, you can manually enable or disable certain capabilities: - -- `--capability=`: Specify individual client capabilities - - Available capabilities: - - `top-level-unions`: Enable support for top-level unions in tool schemas - - `valid-json`: Enable JSON string parsing for arguments - - `refs`: Enable support for $ref pointers in schemas - - `unions`: Enable support for union types (anyOf) in schemas - - `formats`: Enable support for format validations in schemas (e.g. date-time, email) - - `tool-name-length=N`: Set maximum tool name length to N characters - - Example: `--capability=top-level-unions --capability=tool-name-length=40` - - Example: `--capability=top-level-unions,tool-name-length=40` - -### Examples - -1. Filter for read operations on cards: - -```bash ---resource=cards --operation=read -``` - -2. Exclude specific tools while including others: - -```bash ---resource=cards --no-tool=create_cards -``` - -3. Configure for Cursor client with custom max tool name length: - -```bash ---client=cursor --capability=tool-name-length=40 -``` - -4. Complex filtering with multiple criteria: - -```bash ---resource=cards,accounts --operation=read --tag=kyc --no-tool=create_cards -``` +Using this scheme, agents are capable of performing very complex tasks deterministically +and repeatably. ## Running remotely @@ -190,76 +102,3 @@ A configuration JSON for this server might look like this, assuming the server i } } ``` - -The command-line arguments for filtering tools and specifying clients can also be used as query parameters in the URL. -For example, to exclude specific tools while including others, use the URL: - -``` -http://localhost:3000?resource=cards&resource=accounts&no_tool=create_cards -``` - -Or, to configure for the Cursor client, with a custom max tool name length, use the URL: - -``` -http://localhost:3000?client=cursor&capability=tool-name-length%3D40 -``` - -## Importing the tools and server individually - -```js -// Import the server, generated endpoints, or the init function -import { server, endpoints, init } from "hyperspell-mcp/server"; - -// import a specific tool -import listConnections from "hyperspell-mcp/tools/connections/list-connections"; - -// initialize the server and all endpoints -init({ server, endpoints }); - -// manually start server -const transport = new StdioServerTransport(); -await server.connect(transport); - -// or initialize your own server with specific tools -const myServer = new McpServer(...); - -// define your own endpoint -const myCustomEndpoint = { - tool: { - name: 'my_custom_tool', - description: 'My custom tool', - inputSchema: zodToJsonSchema(z.object({ a_property: z.string() })), - }, - handler: async (client: client, args: any) => { - return { myResponse: 'Hello world!' }; - }) -}; - -// initialize the server with your custom endpoints -init({ server: myServer, endpoints: [listConnections, myCustomEndpoint] }); -``` - -## Available Tools - -The following tools are available in this MCP server. - -### Resource `connections`: - -- `list_connections` (`read`): Get accounts the user has connected - -### Resource `integrations`: - -- `list_integrations` (`read`): List all available integrations -- `connect_integration` (`read`): Get a signed url to connect to a given integration. Use the `list_integrations` tool to find the correct `integration_id` for the required provider. - -### Resource `memories`: - -- `update_memory` (`write`): This tool lets you update memory in Hyperspell. -- `add_memory` (`write`): This tool lets you add text, markdown, or JSON to the Hyperspell index so it can be searched later. It will return the `source` and `resource_id` that can be used to later retrieve the processed memory. -- `get_memory` (`read`): This tool lets you retrieve a memory that has been previously indexed. -- `search` (`write`): Search all memories indexed by Hyperspell. Set 'answer' to true to directly answer the query, or to 'false' to simply get all memories related to the query. -- `upload_file` (`write`): This tool lets you upload a file to the Hyperspell index. It will return the `source` and `resource_id` that can be used to later retrieve the processed memory. - -### Resource `auth`: - -- `user_info` (`read`): Get basic info about the current user, including which integrations are currently enabled and which ones are available. diff --git a/packages/mcp-server/src/code-tool.ts b/packages/mcp-server/src/code-tool.ts index a1c92ad..2a2e926 100644 --- a/packages/mcp-server/src/code-tool.ts +++ b/packages/mcp-server/src/code-tool.ts @@ -1,6 +1,6 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { Metadata, ToolCallResult, asTextContentResult } from './tools/types'; +import { McpTool, Metadata, ToolCallResult, asTextContentResult } from './types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; import { readEnv } from './server'; import { WorkerSuccess } from './code-tool-types'; @@ -13,7 +13,7 @@ import { WorkerSuccess } from './code-tool-types'; * * @param endpoints - The endpoints to include in the list. */ -export async function codeTool() { +export function codeTool(): McpTool { const metadata: Metadata = { resource: 'all', operation: 'write', tags: [] }; const tool: Tool = { name: 'execute', diff --git a/packages/mcp-server/src/compat.ts b/packages/mcp-server/src/compat.ts deleted file mode 100644 index f84053c..0000000 --- a/packages/mcp-server/src/compat.ts +++ /dev/null @@ -1,483 +0,0 @@ -import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import { z } from 'zod'; -import { Endpoint } from './tools'; - -export interface ClientCapabilities { - topLevelUnions: boolean; - validJson: boolean; - refs: boolean; - unions: boolean; - formats: boolean; - toolNameLength: number | undefined; -} - -export const defaultClientCapabilities: ClientCapabilities = { - topLevelUnions: true, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: undefined, -}; - -export const ClientType = z.enum(['openai-agents', 'claude', 'claude-code', 'cursor', 'infer']); -export type ClientType = z.infer; - -// Client presets for compatibility -// Note that these could change over time as models get better, so this is -// a best effort. -export const knownClients: Record, ClientCapabilities> = { - 'openai-agents': { - topLevelUnions: false, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: undefined, - }, - claude: { - topLevelUnions: true, - validJson: false, - refs: true, - unions: true, - formats: true, - toolNameLength: undefined, - }, - 'claude-code': { - topLevelUnions: false, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: undefined, - }, - cursor: { - topLevelUnions: false, - validJson: true, - refs: false, - unions: false, - formats: false, - toolNameLength: 50, - }, -}; - -/** - * Attempts to parse strings into JSON objects - */ -export function parseEmbeddedJSON(args: Record, schema: Record) { - let updated = false; - const newArgs: Record = Object.assign({}, args); - - for (const [key, value] of Object.entries(newArgs)) { - if (typeof value === 'string') { - try { - const parsed = JSON.parse(value); - // Only parse if result is a plain object (not array, null, or primitive) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - newArgs[key] = parsed; - updated = true; - } - } catch (e) { - // Not valid JSON, leave as is - } - } - } - - if (updated) { - return newArgs; - } - - return args; -} - -export type JSONSchema = { - type?: string; - properties?: Record; - required?: string[]; - anyOf?: JSONSchema[]; - $ref?: string; - $defs?: Record; - [key: string]: any; -}; - -/** - * Truncates tool names to the specified length while ensuring uniqueness. - * If truncation would cause duplicate names, appends a number to make them unique. - */ -export function truncateToolNames(names: string[], maxLength: number): Map { - if (maxLength <= 0) { - return new Map(); - } - - const renameMap = new Map(); - const usedNames = new Set(); - - const toTruncate = names.filter((name) => name.length > maxLength); - - if (toTruncate.length === 0) { - return renameMap; - } - - const willCollide = - new Set(toTruncate.map((name) => name.slice(0, maxLength - 1))).size < toTruncate.length; - - if (!willCollide) { - for (const name of toTruncate) { - const truncatedName = name.slice(0, maxLength); - renameMap.set(name, truncatedName); - } - } else { - const baseLength = maxLength - 1; - - for (const name of toTruncate) { - const baseName = name.slice(0, baseLength); - let counter = 1; - - while (usedNames.has(baseName + counter)) { - counter++; - } - - const finalName = baseName + counter; - renameMap.set(name, finalName); - usedNames.add(finalName); - } - } - - return renameMap; -} - -/** - * Removes top-level unions from a tool by splitting it into multiple tools, - * one for each variant in the union. - */ -export function removeTopLevelUnions(tool: Tool): Tool[] { - const inputSchema = tool.inputSchema as JSONSchema; - const variants = inputSchema.anyOf; - - if (!variants || !Array.isArray(variants) || variants.length === 0) { - return [tool]; - } - - const defs = inputSchema.$defs || {}; - - return variants.map((variant, index) => { - const variantSchema: JSONSchema = { - ...inputSchema, - ...variant, - type: 'object', - properties: { - ...(inputSchema.properties || {}), - ...(variant.properties || {}), - }, - }; - - delete variantSchema.anyOf; - - if (!variantSchema['description']) { - variantSchema['description'] = tool.description; - } - - const usedDefs = findUsedDefs(variant, defs); - if (Object.keys(usedDefs).length > 0) { - variantSchema.$defs = usedDefs; - } else { - delete variantSchema.$defs; - } - - return { - ...tool, - name: `${tool.name}_${toSnakeCase(variant['title'] || `variant${index + 1}`)}`, - description: variant['description'] || tool.description, - inputSchema: variantSchema, - } as Tool; - }); -} - -function findUsedDefs( - schema: JSONSchema, - defs: Record, - visited: Set = new Set(), -): Record { - const usedDefs: Record = {}; - - if (typeof schema !== 'object' || schema === null) { - return usedDefs; - } - - if (schema.$ref) { - const refParts = schema.$ref.split('/'); - if (refParts[0] === '#' && refParts[1] === '$defs' && refParts[2]) { - const defName = refParts[2]; - const def = defs[defName]; - if (def && !visited.has(schema.$ref)) { - usedDefs[defName] = def; - visited.add(schema.$ref); - Object.assign(usedDefs, findUsedDefs(def, defs, visited)); - visited.delete(schema.$ref); - } - } - return usedDefs; - } - - for (const key in schema) { - if (key !== '$defs' && typeof schema[key] === 'object' && schema[key] !== null) { - Object.assign(usedDefs, findUsedDefs(schema[key] as JSONSchema, defs, visited)); - } - } - - return usedDefs; -} - -// Export for testing -export { findUsedDefs }; - -/** - * Inlines all $refs in a schema, eliminating $defs. - * If a circular reference is detected, the circular property is removed. - */ -export function inlineRefs(schema: JSONSchema): JSONSchema { - if (!schema || typeof schema !== 'object') { - return schema; - } - - const clonedSchema = { ...schema }; - const defs: Record = schema.$defs || {}; - - delete clonedSchema.$defs; - - const result = inlineRefsRecursive(clonedSchema, defs, new Set()); - // The top level can never be null - return result === null ? {} : result; -} - -function inlineRefsRecursive( - schema: JSONSchema, - defs: Record, - refPath: Set, -): JSONSchema | null { - if (!schema || typeof schema !== 'object') { - return schema; - } - - if (Array.isArray(schema)) { - return schema.map((item) => { - const processed = inlineRefsRecursive(item, defs, refPath); - return processed === null ? {} : processed; - }) as JSONSchema; - } - - const result = { ...schema }; - - if ('$ref' in result && typeof result.$ref === 'string') { - if (result.$ref.startsWith('#/$defs/')) { - const refName = result.$ref.split('/').pop() as string; - const def = defs[refName]; - - // If we've already seen this ref in our path, we have a circular reference - if (refPath.has(result.$ref)) { - // For circular references, we completely remove the property - // by returning null. The parent will remove it. - return null; - } - - if (def) { - const newRefPath = new Set(refPath); - newRefPath.add(result.$ref); - - const inlinedDef = inlineRefsRecursive({ ...def }, defs, newRefPath); - - if (inlinedDef === null) { - return { ...result }; - } - - // Merge the inlined definition with the original schema's properties - // but preserve things like description, etc. - const { $ref, ...rest } = result; - return { ...inlinedDef, ...rest }; - } - } - - // Keep external refs as-is - return result; - } - - for (const key in result) { - if (result[key] && typeof result[key] === 'object') { - const processed = inlineRefsRecursive(result[key] as JSONSchema, defs, refPath); - if (processed === null) { - // Remove properties that would cause circular references - delete result[key]; - } else { - result[key] = processed; - } - } - } - - return result; -} - -/** - * Removes anyOf fields from a schema, using only the first variant. - */ -export function removeAnyOf(schema: JSONSchema): JSONSchema { - if (!schema || typeof schema !== 'object') { - return schema; - } - - if (Array.isArray(schema)) { - return schema.map((item) => removeAnyOf(item)) as JSONSchema; - } - - const result = { ...schema }; - - if ('anyOf' in result && Array.isArray(result.anyOf) && result.anyOf.length > 0) { - const firstVariant = result.anyOf[0]; - - if (firstVariant && typeof firstVariant === 'object') { - // Special handling for properties to ensure deep merge - if (firstVariant.properties && result.properties) { - result.properties = { - ...result.properties, - ...(firstVariant.properties as Record), - }; - } else if (firstVariant.properties) { - result.properties = { ...firstVariant.properties }; - } - - for (const key in firstVariant) { - if (key !== 'properties') { - result[key] = firstVariant[key]; - } - } - } - - delete result.anyOf; - } - - for (const key in result) { - if (result[key] && typeof result[key] === 'object') { - result[key] = removeAnyOf(result[key] as JSONSchema); - } - } - - return result; -} - -/** - * Removes format fields from a schema and appends them to the description. - */ -export function removeFormats(schema: JSONSchema, formatsCapability: boolean): JSONSchema { - if (formatsCapability) { - return schema; - } - - if (!schema || typeof schema !== 'object') { - return schema; - } - - if (Array.isArray(schema)) { - return schema.map((item) => removeFormats(item, formatsCapability)) as JSONSchema; - } - - const result = { ...schema }; - - if ('format' in result && typeof result['format'] === 'string') { - const formatStr = `(format: "${result['format']}")`; - - if ('description' in result && typeof result['description'] === 'string') { - result['description'] = `${result['description']} ${formatStr}`; - } else { - result['description'] = formatStr; - } - - delete result['format']; - } - - for (const key in result) { - if (result[key] && typeof result[key] === 'object') { - result[key] = removeFormats(result[key] as JSONSchema, formatsCapability); - } - } - - return result; -} - -/** - * Applies all compatibility transformations to the endpoints based on the provided capabilities. - */ -export function applyCompatibilityTransformations( - endpoints: Endpoint[], - capabilities: ClientCapabilities, -): Endpoint[] { - let transformedEndpoints = [...endpoints]; - - // Handle top-level unions first as this changes tool names - if (!capabilities.topLevelUnions) { - const newEndpoints: Endpoint[] = []; - - for (const endpoint of transformedEndpoints) { - const variantTools = removeTopLevelUnions(endpoint.tool); - - if (variantTools.length === 1) { - newEndpoints.push(endpoint); - } else { - for (const variantTool of variantTools) { - newEndpoints.push({ - ...endpoint, - tool: variantTool, - }); - } - } - } - - transformedEndpoints = newEndpoints; - } - - if (capabilities.toolNameLength) { - const toolNames = transformedEndpoints.map((endpoint) => endpoint.tool.name); - const renameMap = truncateToolNames(toolNames, capabilities.toolNameLength); - - transformedEndpoints = transformedEndpoints.map((endpoint) => ({ - ...endpoint, - tool: { - ...endpoint.tool, - name: renameMap.get(endpoint.tool.name) ?? endpoint.tool.name, - }, - })); - } - - if (!capabilities.refs || !capabilities.unions || !capabilities.formats) { - transformedEndpoints = transformedEndpoints.map((endpoint) => { - let schema = endpoint.tool.inputSchema as JSONSchema; - - if (!capabilities.refs) { - schema = inlineRefs(schema); - } - - if (!capabilities.unions) { - schema = removeAnyOf(schema); - } - - if (!capabilities.formats) { - schema = removeFormats(schema, capabilities.formats); - } - - return { - ...endpoint, - tool: { - ...endpoint.tool, - inputSchema: schema as typeof endpoint.tool.inputSchema, - }, - }; - }); - } - - return transformedEndpoints; -} - -function toSnakeCase(str: string): string { - return str - .replace(/\s+/g, '_') - .replace(/([a-z])([A-Z])/g, '$1_$2') - .toLowerCase(); -} diff --git a/packages/mcp-server/src/docs-search-tool.ts b/packages/mcp-server/src/docs-search-tool.ts index 330c5b9..d755c66 100644 --- a/packages/mcp-server/src/docs-search-tool.ts +++ b/packages/mcp-server/src/docs-search-tool.ts @@ -1,6 +1,6 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { Metadata, asTextContentResult } from './tools/types'; +import { Metadata, asTextContentResult } from './types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; diff --git a/packages/mcp-server/src/dynamic-tools.ts b/packages/mcp-server/src/dynamic-tools.ts deleted file mode 100644 index 26f3a69..0000000 --- a/packages/mcp-server/src/dynamic-tools.ts +++ /dev/null @@ -1,159 +0,0 @@ -import Hyperspell from 'hyperspell'; -import { Endpoint, asTextContentResult, ToolCallResult } from './tools/types'; -import { zodToJsonSchema } from 'zod-to-json-schema'; -import { z } from 'zod'; -import { Cabidela } from '@cloudflare/cabidela'; - -function zodToInputSchema(schema: z.ZodSchema) { - return { - type: 'object' as const, - ...(zodToJsonSchema(schema) as any), - }; -} - -/** - * A list of tools that expose all the endpoints in the API dynamically. - * - * Instead of exposing every endpoint as its own tool, which uses up too many tokens for LLMs to use at once, - * we expose a single tool that can be used to search for endpoints by name, resource, operation, or tag, and then - * a generic endpoint that can be used to invoke any endpoint with the provided arguments. - * - * @param endpoints - The endpoints to include in the list. - */ -export function dynamicTools(endpoints: Endpoint[]): Endpoint[] { - const listEndpointsSchema = z.object({ - search_query: z - .string() - .optional() - .describe( - 'An optional search query to filter the endpoints by. Provide a partial name, resource, operation, or tag to filter the endpoints returned.', - ), - }); - - const listEndpointsTool = { - metadata: { - resource: 'dynamic_tools', - operation: 'read' as const, - tags: [], - }, - tool: { - name: 'list_api_endpoints', - description: 'List or search for all endpoints in the Hyperspell TypeScript API', - inputSchema: zodToInputSchema(listEndpointsSchema), - }, - handler: async ( - client: Hyperspell, - args: Record | undefined, - ): Promise => { - const query = args && listEndpointsSchema.parse(args).search_query?.trim(); - - const filteredEndpoints = - query && query.length > 0 ? - endpoints.filter((endpoint) => { - const fieldsToMatch = [ - endpoint.tool.name, - endpoint.tool.description, - endpoint.metadata.resource, - endpoint.metadata.operation, - ...endpoint.metadata.tags, - ]; - return fieldsToMatch.some((field) => field && field.toLowerCase().includes(query.toLowerCase())); - }) - : endpoints; - - return asTextContentResult({ - tools: filteredEndpoints.map(({ tool, metadata }) => ({ - name: tool.name, - description: tool.description, - resource: metadata.resource, - operation: metadata.operation, - tags: metadata.tags, - })), - }); - }, - }; - - const getEndpointSchema = z.object({ - endpoint: z.string().describe('The name of the endpoint to get the schema for.'), - }); - const getEndpointTool = { - metadata: { - resource: 'dynamic_tools', - operation: 'read' as const, - tags: [], - }, - tool: { - name: 'get_api_endpoint_schema', - description: - 'Get the schema for an endpoint in the Hyperspell TypeScript API. You can use the schema returned by this tool to invoke an endpoint with the `invoke_api_endpoint` tool.', - inputSchema: zodToInputSchema(getEndpointSchema), - }, - handler: async (client: Hyperspell, args: Record | undefined) => { - if (!args) { - throw new Error('No endpoint provided'); - } - const endpointName = getEndpointSchema.parse(args).endpoint; - - const endpoint = endpoints.find((e) => e.tool.name === endpointName); - if (!endpoint) { - throw new Error(`Endpoint ${endpointName} not found`); - } - return asTextContentResult(endpoint.tool); - }, - }; - - const invokeEndpointSchema = z.object({ - endpoint_name: z.string().describe('The name of the endpoint to invoke.'), - args: z - .record(z.string(), z.any()) - .describe( - 'The arguments to pass to the endpoint. This must match the schema returned by the `get_api_endpoint_schema` tool.', - ), - }); - - const invokeEndpointTool = { - metadata: { - resource: 'dynamic_tools', - operation: 'write' as const, - tags: [], - }, - tool: { - name: 'invoke_api_endpoint', - description: - 'Invoke an endpoint in the Hyperspell TypeScript API. Note: use the `list_api_endpoints` tool to get the list of endpoints and `get_api_endpoint_schema` tool to get the schema for an endpoint.', - inputSchema: zodToInputSchema(invokeEndpointSchema), - }, - handler: async ( - client: Hyperspell, - args: Record | undefined, - ): Promise => { - if (!args) { - throw new Error('No endpoint provided'); - } - const { success, data, error } = invokeEndpointSchema.safeParse(args); - if (!success) { - throw new Error(`Invalid arguments for endpoint. ${error?.format()}`); - } - const { endpoint_name, args: endpointArgs } = data; - - const endpoint = endpoints.find((e) => e.tool.name === endpoint_name); - if (!endpoint) { - throw new Error( - `Endpoint ${endpoint_name} not found. Use the \`list_api_endpoints\` tool to get the list of available endpoints.`, - ); - } - - try { - // Try to validate the arguments for a better error message - const cabidela = new Cabidela(endpoint.tool.inputSchema, { fullErrors: true }); - cabidela.validate(endpointArgs); - } catch (error) { - throw new Error(`Invalid arguments for endpoint ${endpoint_name}:\n${error}`); - } - - return await endpoint.handler(client, endpointArgs); - }, - }; - - return [getEndpointTool, listEndpointsTool, invokeEndpointTool]; -} diff --git a/packages/mcp-server/src/filtering.ts b/packages/mcp-server/src/filtering.ts deleted file mode 100644 index eaae0fc..0000000 --- a/packages/mcp-server/src/filtering.ts +++ /dev/null @@ -1,18 +0,0 @@ -// @ts-nocheck -import initJq from 'jq-web'; - -export async function maybeFilter(jqFilter: unknown | undefined, response: any): Promise { - if (jqFilter && typeof jqFilter === 'string') { - return await jq(response, jqFilter); - } else { - return response; - } -} - -async function jq(json: any, jqFilter: string) { - return (await initJq).json(json, jqFilter); -} - -export function isJqError(error: any): error is Error { - return error instanceof Error && 'stderr' in error; -} diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index 8451700..2366d8f 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -4,38 +4,21 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; -import { fromError } from 'zod-validation-error/v3'; -import { McpOptions, parseQueryOptions } from './options'; +import { McpOptions } from './options'; import { ClientOptions, initMcpServer, newMcpServer } from './server'; import { parseAuthHeaders } from './headers'; const newServer = ({ clientOptions, - mcpOptions: defaultMcpOptions, req, res, }: { clientOptions: ClientOptions; - mcpOptions: McpOptions; req: express.Request; res: express.Response; }): McpServer | null => { const server = newMcpServer(); - let mcpOptions: McpOptions; - try { - mcpOptions = parseQueryOptions(defaultMcpOptions, req.query); - } catch (error) { - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: `Invalid request: ${fromError(error)}`, - }, - }); - return null; - } - try { const authOptions = parseAuthHeaders(req); initMcpServer({ @@ -44,7 +27,6 @@ const newServer = ({ ...clientOptions, ...authOptions, }, - mcpOptions, }); } catch (error) { res.status(401).json({ diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 4850a0e..0f6dd42 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1,20 +1,15 @@ #!/usr/bin/env node import { selectTools } from './server'; -import { Endpoint, endpoints } from './tools'; import { McpOptions, parseCLIOptions } from './options'; import { launchStdioServer } from './stdio'; import { launchStreamableHTTPServer } from './http'; +import type { McpTool } from './types'; async function main() { const options = parseOptionsOrError(); - if (options.list) { - listAllTools(); - return; - } - - const selectedTools = await selectToolsOrError(endpoints, options); + const selectedTools = await selectToolsOrError(options); console.error( `MCP Server starting with ${selectedTools.length} tools:`, @@ -23,7 +18,7 @@ async function main() { switch (options.transport) { case 'stdio': - await launchStdioServer(options); + await launchStdioServer(); break; case 'http': await launchStreamableHTTPServer(options, options.port ?? options.socket); @@ -47,9 +42,9 @@ function parseOptionsOrError() { } } -async function selectToolsOrError(endpoints: Endpoint[], options: McpOptions): Promise { +async function selectToolsOrError(options: McpOptions): Promise { try { - const includedTools = await selectTools(endpoints, options); + const includedTools = selectTools(options); if (includedTools.length === 0) { console.error('No tools match the provided filters.'); process.exit(1); @@ -64,45 +59,3 @@ async function selectToolsOrError(endpoints: Endpoint[], options: McpOptions): P process.exit(1); } } - -function listAllTools() { - if (endpoints.length === 0) { - console.log('No tools available.'); - return; - } - console.log('Available tools:\n'); - - // Group endpoints by resource - const resourceGroups = new Map(); - - for (const endpoint of endpoints) { - const resource = endpoint.metadata.resource; - if (!resourceGroups.has(resource)) { - resourceGroups.set(resource, []); - } - resourceGroups.get(resource)!.push(endpoint); - } - - // Sort resources alphabetically - const sortedResources = Array.from(resourceGroups.keys()).sort(); - - // Display hierarchically by resource - for (const resource of sortedResources) { - console.log(`Resource: ${resource}`); - - const resourceEndpoints = resourceGroups.get(resource)!; - // Sort endpoints by tool name - resourceEndpoints.sort((a, b) => a.tool.name.localeCompare(b.tool.name)); - - for (const endpoint of resourceEndpoints) { - const { - tool, - metadata: { operation, tags }, - } = endpoint; - - console.log(` - ${tool.name} (${operation}) ${tags.length > 0 ? `tags: ${tags.join(', ')}` : ''}`); - console.log(` Description: ${tool.description}`); - } - console.log(''); - } -} diff --git a/packages/mcp-server/src/options.ts b/packages/mcp-server/src/options.ts index b6ff597..6c8bb8d 100644 --- a/packages/mcp-server/src/options.ts +++ b/packages/mcp-server/src/options.ts @@ -2,140 +2,31 @@ import qs from 'qs'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import z from 'zod'; -import { endpoints, Filter } from './tools'; -import { ClientCapabilities, knownClients, ClientType } from './compat'; export type CLIOptions = McpOptions & { - list: boolean; transport: 'stdio' | 'http'; port: number | undefined; socket: string | undefined; }; export type McpOptions = { - client?: ClientType | undefined; - includeDynamicTools?: boolean | undefined; - includeAllTools?: boolean | undefined; - includeCodeTools?: boolean | undefined; includeDocsTools?: boolean | undefined; - filters?: Filter[] | undefined; - capabilities?: Partial | undefined; }; -const CAPABILITY_CHOICES = [ - 'top-level-unions', - 'valid-json', - 'refs', - 'unions', - 'formats', - 'tool-name-length', -] as const; - -type Capability = (typeof CAPABILITY_CHOICES)[number]; - -function parseCapabilityValue(cap: string): { name: Capability; value?: number } { - if (cap.startsWith('tool-name-length=')) { - const parts = cap.split('='); - if (parts.length === 2) { - const length = parseInt(parts[1]!, 10); - if (!isNaN(length)) { - return { name: 'tool-name-length', value: length }; - } - throw new Error(`Invalid tool-name-length value: ${parts[1]}. Expected a number.`); - } - throw new Error(`Invalid format for tool-name-length. Expected tool-name-length=N.`); - } - if (!CAPABILITY_CHOICES.includes(cap as Capability)) { - throw new Error(`Unknown capability: ${cap}. Valid capabilities are: ${CAPABILITY_CHOICES.join(', ')}`); - } - return { name: cap as Capability }; -} - export function parseCLIOptions(): CLIOptions { const opts = yargs(hideBin(process.argv)) .option('tools', { type: 'string', array: true, - choices: ['dynamic', 'all', 'code', 'docs'], + choices: ['code', 'docs'], description: 'Use dynamic tools or all tools', }) .option('no-tools', { type: 'string', array: true, - choices: ['dynamic', 'all', 'code', 'docs'], + choices: ['code', 'docs'], description: 'Do not use any dynamic or all tools', }) - .option('tool', { - type: 'string', - array: true, - description: 'Include tools matching the specified names', - }) - .option('resource', { - type: 'string', - array: true, - description: 'Include tools matching the specified resources', - }) - .option('operation', { - type: 'string', - array: true, - choices: ['read', 'write'], - description: 'Include tools matching the specified operations', - }) - .option('tag', { - type: 'string', - array: true, - description: 'Include tools with the specified tags', - }) - .option('no-tool', { - type: 'string', - array: true, - description: 'Exclude tools matching the specified names', - }) - .option('no-resource', { - type: 'string', - array: true, - description: 'Exclude tools matching the specified resources', - }) - .option('no-operation', { - type: 'string', - array: true, - description: 'Exclude tools matching the specified operations', - }) - .option('no-tag', { - type: 'string', - array: true, - description: 'Exclude tools with the specified tags', - }) - .option('list', { - type: 'boolean', - description: 'List all tools and exit', - }) - .option('client', { - type: 'string', - choices: Object.keys(knownClients), - description: 'Specify the MCP client being used', - }) - .option('capability', { - type: 'string', - array: true, - description: 'Specify client capabilities', - coerce: (values: string[]) => { - return values.flatMap((v) => v.split(',')); - }, - }) - .option('no-capability', { - type: 'string', - array: true, - description: 'Unset client capabilities', - choices: CAPABILITY_CHOICES, - coerce: (values: string[]) => { - return values.flatMap((v) => v.split(',')); - }, - }) - .option('describe-capabilities', { - type: 'boolean', - description: 'Print detailed explanation of client capabilities and exit', - }) .option('transport', { type: 'string', choices: ['stdio', 'http'], @@ -152,122 +43,19 @@ export function parseCLIOptions(): CLIOptions { }) .help(); - for (const [command, desc] of examples()) { - opts.example(command, desc); - } - const argv = opts.parseSync(); - // Handle describe-capabilities flag - if (argv.describeCapabilities) { - console.log(getCapabilitiesExplanation()); - process.exit(0); - } - - const filters: Filter[] = []; - - // Helper function to support comma-separated values - const splitValues = (values: string[] | undefined): string[] => { - if (!values) return []; - return values.flatMap((v) => v.split(',')); - }; - - for (const tag of splitValues(argv.tag)) { - filters.push({ type: 'tag', op: 'include', value: tag }); - } - - for (const tag of splitValues(argv.noTag)) { - filters.push({ type: 'tag', op: 'exclude', value: tag }); - } - - for (const resource of splitValues(argv.resource)) { - filters.push({ type: 'resource', op: 'include', value: resource }); - } - - for (const resource of splitValues(argv.noResource)) { - filters.push({ type: 'resource', op: 'exclude', value: resource }); - } - - for (const tool of splitValues(argv.tool)) { - filters.push({ type: 'tool', op: 'include', value: tool }); - } - - for (const tool of splitValues(argv.noTool)) { - filters.push({ type: 'tool', op: 'exclude', value: tool }); - } - - for (const operation of splitValues(argv.operation)) { - filters.push({ type: 'operation', op: 'include', value: operation }); - } - - for (const operation of splitValues(argv.noOperation)) { - filters.push({ type: 'operation', op: 'exclude', value: operation }); - } - - // Parse client capabilities - const clientCapabilities: Partial = {}; - - // Apply individual capability overrides - if (Array.isArray(argv.capability)) { - for (const cap of argv.capability) { - const parsedCap = parseCapabilityValue(cap); - if (parsedCap.name === 'top-level-unions') { - clientCapabilities.topLevelUnions = true; - } else if (parsedCap.name === 'valid-json') { - clientCapabilities.validJson = true; - } else if (parsedCap.name === 'refs') { - clientCapabilities.refs = true; - } else if (parsedCap.name === 'unions') { - clientCapabilities.unions = true; - } else if (parsedCap.name === 'formats') { - clientCapabilities.formats = true; - } else if (parsedCap.name === 'tool-name-length') { - clientCapabilities.toolNameLength = parsedCap.value; - } - } - } - - // Handle no-capability options to unset capabilities - if (Array.isArray(argv.noCapability)) { - for (const cap of argv.noCapability) { - if (cap === 'top-level-unions') { - clientCapabilities.topLevelUnions = false; - } else if (cap === 'valid-json') { - clientCapabilities.validJson = false; - } else if (cap === 'refs') { - clientCapabilities.refs = false; - } else if (cap === 'unions') { - clientCapabilities.unions = false; - } else if (cap === 'formats') { - clientCapabilities.formats = false; - } else if (cap === 'tool-name-length') { - clientCapabilities.toolNameLength = undefined; - } - } - } - - const shouldIncludeToolType = (toolType: 'dynamic' | 'all' | 'code' | 'docs') => + const shouldIncludeToolType = (toolType: 'code' | 'docs') => argv.noTools?.includes(toolType) ? false : argv.tools?.includes(toolType) ? true : undefined; - const includeDynamicTools = shouldIncludeToolType('dynamic'); - const includeAllTools = shouldIncludeToolType('all'); - const includeCodeTools = shouldIncludeToolType('code'); const includeDocsTools = shouldIncludeToolType('docs'); const transport = argv.transport as 'stdio' | 'http'; - const client = argv.client as ClientType; return { - client: client && client !== 'infer' && knownClients[client] ? client : undefined, - includeDynamicTools, - includeAllTools, - includeCodeTools, includeDocsTools, - filters, - capabilities: clientCapabilities, - list: argv.list || false, transport, port: argv.port, socket: argv.socket, @@ -284,190 +72,21 @@ const coerceArray = (zodType: T) => ); const QueryOptions = z.object({ - tools: coerceArray(z.enum(['dynamic', 'all', 'code', 'docs'])).describe('Specify which MCP tools to use'), - no_tools: coerceArray(z.enum(['dynamic', 'all', 'code', 'docs'])).describe( - 'Specify which MCP tools to not use.', - ), + tools: coerceArray(z.enum(['code', 'docs'])).describe('Specify which MCP tools to use'), + no_tools: coerceArray(z.enum(['code', 'docs'])).describe('Specify which MCP tools to not use.'), tool: coerceArray(z.string()).describe('Include tools matching the specified names'), - resource: coerceArray(z.string()).describe('Include tools matching the specified resources'), - operation: coerceArray(z.enum(['read', 'write'])).describe( - 'Include tools matching the specified operations', - ), - tag: coerceArray(z.string()).describe('Include tools with the specified tags'), - no_tool: coerceArray(z.string()).describe('Exclude tools matching the specified names'), - no_resource: coerceArray(z.string()).describe('Exclude tools matching the specified resources'), - no_operation: coerceArray(z.enum(['read', 'write'])).describe( - 'Exclude tools matching the specified operations', - ), - no_tag: coerceArray(z.string()).describe('Exclude tools with the specified tags'), - client: ClientType.optional().describe('Specify the MCP client being used'), - capability: coerceArray(z.string()).describe('Specify client capabilities'), - no_capability: coerceArray(z.enum(CAPABILITY_CHOICES)).describe('Unset client capabilities'), }); export function parseQueryOptions(defaultOptions: McpOptions, query: unknown): McpOptions { const queryObject = typeof query === 'string' ? qs.parse(query) : query; const queryOptions = QueryOptions.parse(queryObject); - const filters: Filter[] = [...(defaultOptions.filters ?? [])]; - - for (const resource of queryOptions.resource || []) { - filters.push({ type: 'resource', op: 'include', value: resource }); - } - for (const operation of queryOptions.operation || []) { - filters.push({ type: 'operation', op: 'include', value: operation }); - } - for (const tag of queryOptions.tag || []) { - filters.push({ type: 'tag', op: 'include', value: tag }); - } - for (const tool of queryOptions.tool || []) { - filters.push({ type: 'tool', op: 'include', value: tool }); - } - for (const resource of queryOptions.no_resource || []) { - filters.push({ type: 'resource', op: 'exclude', value: resource }); - } - for (const operation of queryOptions.no_operation || []) { - filters.push({ type: 'operation', op: 'exclude', value: operation }); - } - for (const tag of queryOptions.no_tag || []) { - filters.push({ type: 'tag', op: 'exclude', value: tag }); - } - for (const tool of queryOptions.no_tool || []) { - filters.push({ type: 'tool', op: 'exclude', value: tool }); - } - - // Parse client capabilities - const clientCapabilities: Partial = { ...defaultOptions.capabilities }; - - for (const cap of queryOptions.capability || []) { - const parsed = parseCapabilityValue(cap); - if (parsed.name === 'top-level-unions') { - clientCapabilities.topLevelUnions = true; - } else if (parsed.name === 'valid-json') { - clientCapabilities.validJson = true; - } else if (parsed.name === 'refs') { - clientCapabilities.refs = true; - } else if (parsed.name === 'unions') { - clientCapabilities.unions = true; - } else if (parsed.name === 'formats') { - clientCapabilities.formats = true; - } else if (parsed.name === 'tool-name-length') { - clientCapabilities.toolNameLength = parsed.value; - } - } - - for (const cap of queryOptions.no_capability || []) { - if (cap === 'top-level-unions') { - clientCapabilities.topLevelUnions = false; - } else if (cap === 'valid-json') { - clientCapabilities.validJson = false; - } else if (cap === 'refs') { - clientCapabilities.refs = false; - } else if (cap === 'unions') { - clientCapabilities.unions = false; - } else if (cap === 'formats') { - clientCapabilities.formats = false; - } else if (cap === 'tool-name-length') { - clientCapabilities.toolNameLength = undefined; - } - } - - let dynamicTools: boolean | undefined = - queryOptions.no_tools && queryOptions.no_tools?.includes('dynamic') ? false - : queryOptions.tools?.includes('dynamic') ? true - : defaultOptions.includeDynamicTools; - - let allTools: boolean | undefined = - queryOptions.no_tools && queryOptions.no_tools?.includes('all') ? false - : queryOptions.tools?.includes('all') ? true - : defaultOptions.includeAllTools; - let docsTools: boolean | undefined = queryOptions.no_tools && queryOptions.no_tools?.includes('docs') ? false : queryOptions.tools?.includes('docs') ? true : defaultOptions.includeDocsTools; - let codeTools: boolean | undefined = - queryOptions.no_tools && queryOptions.no_tools?.includes('code') ? false - : queryOptions.tools?.includes('code') && defaultOptions.includeCodeTools ? true - : defaultOptions.includeCodeTools; - return { - client: queryOptions.client ?? defaultOptions.client, - includeDynamicTools: dynamicTools, - includeAllTools: allTools, - includeCodeTools: codeTools, includeDocsTools: docsTools, - filters, - capabilities: clientCapabilities, }; } - -function getCapabilitiesExplanation(): string { - return ` -Client Capabilities Explanation: - -Different Language Models (LLMs) and the MCP clients that use them have varying limitations in how they handle tool schemas. Capability flags allow you to inform the MCP server about these limitations. - -When a capability flag is set to false, the MCP server will automatically adjust the tool schemas to work around that limitation, ensuring broader compatibility. - -Available Capabilities: - -# top-level-unions -Some clients/LLMs do not support JSON schemas with a union type (anyOf) at the root level. If a client lacks this capability, the MCP server splits tools with top-level unions into multiple separate tools, one for each variant in the union. - -# refs -Some clients/LLMs do not support $ref pointers for schema reuse. If a client lacks this capability, the MCP server automatically inlines all references ($defs) directly into the schema. Properties that would cause circular references are removed during this process. - -# valid-json -Some clients/LLMs may incorrectly send arguments as a JSON-encoded string instead of a proper JSON object. If a client *has* this capability, the MCP server will attempt to parse string values as JSON if the initial validation against the schema fails. - -# unions -Some clients/LLMs do not support union types (anyOf) in JSON schemas. If a client lacks this capability, the MCP server removes all anyOf fields and uses only the first variant as the schema. - -# formats -Some clients/LLMs do not support the 'format' keyword in JSON Schema specifications. If a client lacks this capability, the MCP server removes all format fields and appends the format information to the field's description in parentheses. - -# tool-name-length=N -Some clients/LLMs impose a maximum length on tool names. If this capability is set, the MCP server will automatically truncate tool names exceeding the specified length (N), ensuring uniqueness by appending numbers if necessary. - -Client Presets (--client): -Presets like '--client=openai-agents' or '--client=cursor' automatically configure these capabilities based on current known limitations of those clients, simplifying setup. - -Current presets: -${JSON.stringify(knownClients, null, 2)} - `; -} - -function examples(): [string, string][] { - const firstEndpoint = endpoints[0]!; - const secondEndpoint = - endpoints.find((e) => e.metadata.resource !== firstEndpoint.metadata.resource) || endpoints[1]; - const tag = endpoints.find((e) => e.metadata.tags.length > 0)?.metadata.tags[0]; - const otherEndpoint = secondEndpoint || firstEndpoint; - - return [ - [ - `--tool="${firstEndpoint.tool.name}" ${secondEndpoint ? `--tool="${secondEndpoint.tool.name}"` : ''}`, - 'Include tools by name', - ], - [ - `--resource="${firstEndpoint.metadata.resource}" --operation="read"`, - 'Filter by resource and operation', - ], - [ - `--resource="${otherEndpoint.metadata.resource}*" --no-tool="${otherEndpoint.tool.name}"`, - 'Use resource wildcards and exclusions', - ], - [`--client="cursor"`, 'Adjust schemas to be more compatible with Cursor'], - [ - `--capability="top-level-unions" --capability="tool-name-length=40"`, - 'Specify individual client capabilities', - ], - [ - `--client="cursor" --no-capability="tool-name-length"`, - 'Use cursor client preset but remove tool name length limit', - ], - ...(tag ? [[`--tag="${tag}"`, 'Filter based on tags'] as [string, string]] : []), - ]; -} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index ba3c8e3..a14e8d5 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -2,33 +2,20 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { Endpoint, endpoints, HandlerFunction, query } from './tools'; import { CallToolRequestSchema, ListToolsRequestSchema, SetLevelRequestSchema, - Implementation, - Tool, } from '@modelcontextprotocol/sdk/types.js'; import { ClientOptions } from 'hyperspell'; import Hyperspell from 'hyperspell'; -import { - applyCompatibilityTransformations, - ClientCapabilities, - defaultClientCapabilities, - knownClients, - parseEmbeddedJSON, -} from './compat'; -import { dynamicTools } from './dynamic-tools'; import { codeTool } from './code-tool'; import docsSearchTool from './docs-search-tool'; import { McpOptions } from './options'; +import { HandlerFunction, McpTool } from './types'; export { McpOptions } from './options'; -export { ClientType } from './compat'; -export { Filter } from './tools'; export { ClientOptions } from 'hyperspell'; -export { endpoints } from './tools'; export const newMcpServer = () => new McpServer( @@ -52,25 +39,6 @@ export function initMcpServer(params: { mcpOptions?: McpOptions; }) { const server = params.server instanceof McpServer ? params.server.server : params.server; - const mcpOptions = params.mcpOptions ?? {}; - - let providedEndpoints: Endpoint[] | null = null; - let endpointMap: Record | null = null; - - const initTools = async (implementation?: Implementation) => { - if (implementation && (!mcpOptions.client || mcpOptions.client === 'infer')) { - mcpOptions.client = - implementation.name.toLowerCase().includes('claude') ? 'claude' - : implementation.name.toLowerCase().includes('cursor') ? 'cursor' - : undefined; - mcpOptions.capabilities = { - ...(mcpOptions.client && knownClients[mcpOptions.client]), - ...mcpOptions.capabilities, - }; - } - providedEndpoints ??= await selectTools(endpoints, mcpOptions); - endpointMap ??= Object.fromEntries(providedEndpoints.map((endpoint) => [endpoint.tool.name, endpoint])); - }; const logAtLevel = (level: 'debug' | 'info' | 'warning' | 'error') => @@ -97,26 +65,23 @@ export function initMcpServer(params: { }, }); + const providedTools = selectTools(params.mcpOptions); + const toolMap = Object.fromEntries(providedTools.map((mcpTool) => [mcpTool.tool.name, mcpTool])); + server.setRequestHandler(ListToolsRequestSchema, async () => { - if (providedEndpoints === null) { - await initTools(server.getClientVersion()); - } return { - tools: providedEndpoints!.map((endpoint) => endpoint.tool), + tools: providedTools.map((mcpTool) => mcpTool.tool), }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (endpointMap === null) { - await initTools(server.getClientVersion()); - } const { name, arguments: args } = request.params; - const endpoint = endpointMap![name]; - if (!endpoint) { + const mcpTool = toolMap[name]; + if (!mcpTool) { throw new Error(`Unknown tool: ${name}`); } - return executeHandler(endpoint.tool, endpoint.handler, client, args, mcpOptions.capabilities); + return executeHandler(mcpTool.handler, client, args); }); server.setRequestHandler(SetLevelRequestSchema, async (request) => { @@ -146,47 +111,22 @@ export function initMcpServer(params: { /** * Selects the tools to include in the MCP Server based on the provided options. */ -export async function selectTools(endpoints: Endpoint[], options?: McpOptions): Promise { - const filteredEndpoints = query(options?.filters ?? [], endpoints); - - let includedTools = filteredEndpoints.slice(); - - if (includedTools.length > 0) { - if (options?.includeDynamicTools) { - includedTools = dynamicTools(includedTools); - } - } else { - if (options?.includeAllTools) { - includedTools = endpoints.slice(); - } else if (options?.includeDynamicTools) { - includedTools = dynamicTools(endpoints); - } else if (options?.includeCodeTools) { - includedTools = [await codeTool()]; - } else { - includedTools = endpoints.slice(); - } - } +export function selectTools(options?: McpOptions): McpTool[] { + const includedTools = [codeTool()]; if (options?.includeDocsTools ?? true) { includedTools.push(docsSearchTool); } - const capabilities = { ...defaultClientCapabilities, ...options?.capabilities }; - return applyCompatibilityTransformations(includedTools, capabilities); + return includedTools; } /** * Runs the provided handler with the given client and arguments. */ export async function executeHandler( - tool: Tool, handler: HandlerFunction, client: Hyperspell, args: Record | undefined, - compatibilityOptions?: Partial, ) { - const options = { ...defaultClientCapabilities, ...compatibilityOptions }; - if (!options.validJson && args) { - args = parseEmbeddedJSON(args, tool.inputSchema); - } return await handler(client, args || {}); } diff --git a/packages/mcp-server/src/stdio.ts b/packages/mcp-server/src/stdio.ts index d902a5b..f07696f 100644 --- a/packages/mcp-server/src/stdio.ts +++ b/packages/mcp-server/src/stdio.ts @@ -1,11 +1,10 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { initMcpServer, newMcpServer } from './server'; -import { McpOptions } from './options'; -export const launchStdioServer = async (options: McpOptions) => { +export const launchStdioServer = async () => { const server = newMcpServer(); - initMcpServer({ server, mcpOptions: options }); + initMcpServer({ server }); const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts deleted file mode 100644 index 7e516de..0000000 --- a/packages/mcp-server/src/tools.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './tools/index'; diff --git a/packages/mcp-server/src/tools/auth/user-info.ts b/packages/mcp-server/src/tools/auth/user-info.ts deleted file mode 100644 index a9b5acb..0000000 --- a/packages/mcp-server/src/tools/auth/user-info.ts +++ /dev/null @@ -1,51 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -import { isJqError, maybeFilter } from 'hyperspell-mcp/filtering'; -import { Metadata, asErrorResult, asTextContentResult } from 'hyperspell-mcp/tools/types'; - -import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import Hyperspell from 'hyperspell'; - -export const metadata: Metadata = { - resource: 'auth', - operation: 'read', - tags: [], - httpMethod: 'get', - httpPath: '/auth/me', - operationId: 'user_info_auth_me_get', -}; - -export const tool: Tool = { - name: 'user_info', - description: - "When using this tool, always use the `jq_filter` parameter to reduce the response size and improve performance.\n\nOnly omit if you're sure you don't need the data.\n\nGet basic info about the current user, including which integrations are currently enabled and which ones are available.\n\n# Response Schema\n```json\n{\n $ref: '#/$defs/auth_me_response',\n $defs: {\n auth_me_response: {\n type: 'object',\n title: 'User',\n properties: {\n id: {\n type: 'string',\n title: 'Id',\n description: 'The user\\'s id'\n },\n app: {\n type: 'object',\n title: 'AppInfo',\n description: 'The Hyperspell app\\'s id this user belongs to',\n properties: {\n id: {\n type: 'string',\n title: 'Id',\n description: 'The Hyperspell app\\'s id this user belongs to'\n },\n icon_url: {\n type: 'string',\n title: 'Icon Url',\n description: 'The app\\'s icon'\n },\n name: {\n type: 'string',\n title: 'Name',\n description: 'The app\\'s name'\n },\n redirect_url: {\n type: 'string',\n title: 'Redirect Url',\n description: 'The app\\'s redirect URL'\n }\n },\n required: [ 'id',\n 'icon_url',\n 'name',\n 'redirect_url'\n ]\n },\n available_integrations: {\n type: 'array',\n title: 'Available Integrations',\n description: 'All integrations available for the app',\n items: {\n type: 'string',\n title: 'DocumentProviders',\n enum: [ 'collections',\n 'vault',\n 'web_crawler',\n 'notion',\n 'slack',\n 'google_calendar',\n 'reddit',\n 'box',\n 'google_drive',\n 'airtable',\n 'algolia',\n 'amplitude',\n 'asana',\n 'ashby',\n 'bamboohr',\n 'basecamp',\n 'bubbles',\n 'calendly',\n 'confluence',\n 'clickup',\n 'datadog',\n 'deel',\n 'discord',\n 'dropbox',\n 'exa',\n 'facebook',\n 'front',\n 'github',\n 'gitlab',\n 'google_docs',\n 'google_mail',\n 'google_sheet',\n 'hubspot',\n 'jira',\n 'linear',\n 'microsoft_teams',\n 'mixpanel',\n 'monday',\n 'outlook',\n 'perplexity',\n 'rippling',\n 'salesforce',\n 'segment',\n 'todoist',\n 'twitter',\n 'zoom'\n ]\n }\n },\n installed_integrations: {\n type: 'array',\n title: 'Installed Integrations',\n description: 'All integrations installed for the user',\n items: {\n type: 'string',\n title: 'DocumentProviders',\n enum: [ 'collections',\n 'vault',\n 'web_crawler',\n 'notion',\n 'slack',\n 'google_calendar',\n 'reddit',\n 'box',\n 'google_drive',\n 'airtable',\n 'algolia',\n 'amplitude',\n 'asana',\n 'ashby',\n 'bamboohr',\n 'basecamp',\n 'bubbles',\n 'calendly',\n 'confluence',\n 'clickup',\n 'datadog',\n 'deel',\n 'discord',\n 'dropbox',\n 'exa',\n 'facebook',\n 'front',\n 'github',\n 'gitlab',\n 'google_docs',\n 'google_mail',\n 'google_sheet',\n 'hubspot',\n 'jira',\n 'linear',\n 'microsoft_teams',\n 'mixpanel',\n 'monday',\n 'outlook',\n 'perplexity',\n 'rippling',\n 'salesforce',\n 'segment',\n 'todoist',\n 'twitter',\n 'zoom'\n ]\n }\n },\n token_expiration: {\n type: 'string',\n title: 'Token Expiration',\n description: 'The expiration time of the user token used to make the request',\n format: 'date-time'\n }\n },\n required: [ 'id',\n 'app',\n 'available_integrations',\n 'installed_integrations',\n 'token_expiration'\n ]\n }\n }\n}\n```", - inputSchema: { - type: 'object', - properties: { - jq_filter: { - type: 'string', - title: 'jq Filter', - description: - 'A jq filter to apply to the response to include certain fields. Consult the output schema in the tool description to see the fields that are available.\n\nFor example: to include only the `name` field in every object of a results array, you can provide ".results[].name".\n\nFor more information, see the [jq documentation](https://jqlang.org/manual/).', - }, - }, - required: [], - }, - annotations: { - readOnlyHint: true, - }, -}; - -export const handler = async (client: Hyperspell, args: Record | undefined) => { - const { jq_filter } = args as any; - try { - return asTextContentResult(await maybeFilter(jq_filter, await client.auth.me())); - } catch (error) { - if (error instanceof Hyperspell.APIError || isJqError(error)) { - return asErrorResult(error.message); - } - throw error; - } -}; - -export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/connections/list-connections.ts b/packages/mcp-server/src/tools/connections/list-connections.ts deleted file mode 100644 index 7f38075..0000000 --- a/packages/mcp-server/src/tools/connections/list-connections.ts +++ /dev/null @@ -1,51 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -import { isJqError, maybeFilter } from 'hyperspell-mcp/filtering'; -import { Metadata, asErrorResult, asTextContentResult } from 'hyperspell-mcp/tools/types'; - -import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import Hyperspell from 'hyperspell'; - -export const metadata: Metadata = { - resource: 'connections', - operation: 'read', - tags: [], - httpMethod: 'get', - httpPath: '/connections/list', - operationId: 'list_connections_connections_list_get', -}; - -export const tool: Tool = { - name: 'list_connections', - description: - "When using this tool, always use the `jq_filter` parameter to reduce the response size and improve performance.\n\nOnly omit if you're sure you don't need the data.\n\nGet accounts the user has connected\n\n# Response Schema\n```json\n{\n $ref: '#/$defs/connection_list_response',\n $defs: {\n connection_list_response: {\n type: 'object',\n title: 'ListConnectionsResponse',\n properties: {\n connections: {\n type: 'array',\n title: 'Connections',\n items: {\n type: 'object',\n title: 'ConnectionInfo',\n properties: {\n id: {\n type: 'string',\n title: 'Id',\n description: 'The connection\\'s id'\n },\n integration_id: {\n type: 'string',\n title: 'Integration Id',\n description: 'The connection\\'s integration id'\n },\n label: {\n type: 'string',\n title: 'Label',\n description: 'The connection\\'s label'\n },\n provider: {\n type: 'string',\n title: 'DocumentProviders',\n description: 'The connection\\'s provider',\n enum: [ 'collections',\n 'vault',\n 'web_crawler',\n 'notion',\n 'slack',\n 'google_calendar',\n 'reddit',\n 'box',\n 'google_drive',\n 'airtable',\n 'algolia',\n 'amplitude',\n 'asana',\n 'ashby',\n 'bamboohr',\n 'basecamp',\n 'bubbles',\n 'calendly',\n 'confluence',\n 'clickup',\n 'datadog',\n 'deel',\n 'discord',\n 'dropbox',\n 'exa',\n 'facebook',\n 'front',\n 'github',\n 'gitlab',\n 'google_docs',\n 'google_mail',\n 'google_sheet',\n 'hubspot',\n 'jira',\n 'linear',\n 'microsoft_teams',\n 'mixpanel',\n 'monday',\n 'outlook',\n 'perplexity',\n 'rippling',\n 'salesforce',\n 'segment',\n 'todoist',\n 'twitter',\n 'zoom'\n ]\n }\n },\n required: [ 'id',\n 'integration_id',\n 'label',\n 'provider'\n ]\n }\n }\n },\n required: [ 'connections'\n ]\n }\n }\n}\n```", - inputSchema: { - type: 'object', - properties: { - jq_filter: { - type: 'string', - title: 'jq Filter', - description: - 'A jq filter to apply to the response to include certain fields. Consult the output schema in the tool description to see the fields that are available.\n\nFor example: to include only the `name` field in every object of a results array, you can provide ".results[].name".\n\nFor more information, see the [jq documentation](https://jqlang.org/manual/).', - }, - }, - required: [], - }, - annotations: { - readOnlyHint: true, - }, -}; - -export const handler = async (client: Hyperspell, args: Record | undefined) => { - const { jq_filter } = args as any; - try { - return asTextContentResult(await maybeFilter(jq_filter, await client.connections.list())); - } catch (error) { - if (error instanceof Hyperspell.APIError || isJqError(error)) { - return asErrorResult(error.message); - } - throw error; - } -}; - -export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts deleted file mode 100644 index e7cf91c..0000000 --- a/packages/mcp-server/src/tools/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -import { Metadata, Endpoint, HandlerFunction } from './types'; - -export { Metadata, Endpoint, HandlerFunction }; - -import list_connections from './connections/list-connections'; -import list_integrations from './integrations/list-integrations'; -import connect_integration from './integrations/connect-integration'; -import update_memory from './memories/update-memory'; -import add_memory from './memories/add-memory'; -import get_memory from './memories/get-memory'; -import search from './memories/search'; -import upload_file from './memories/upload-file'; -import user_info from './auth/user-info'; - -export const endpoints: Endpoint[] = []; - -function addEndpoint(endpoint: Endpoint) { - endpoints.push(endpoint); -} - -addEndpoint(list_connections); -addEndpoint(list_integrations); -addEndpoint(connect_integration); -addEndpoint(update_memory); -addEndpoint(add_memory); -addEndpoint(get_memory); -addEndpoint(search); -addEndpoint(upload_file); -addEndpoint(user_info); - -export type Filter = { - type: 'resource' | 'operation' | 'tag' | 'tool'; - op: 'include' | 'exclude'; - value: string; -}; - -export function query(filters: Filter[], endpoints: Endpoint[]): Endpoint[] { - const allExcludes = filters.length > 0 && filters.every((filter) => filter.op === 'exclude'); - const unmatchedFilters = new Set(filters); - - const filtered = endpoints.filter((endpoint: Endpoint) => { - let included = false || allExcludes; - - for (const filter of filters) { - if (match(filter, endpoint)) { - unmatchedFilters.delete(filter); - included = filter.op === 'include'; - } - } - - return included; - }); - - // Check if any filters didn't match - const unmatched = Array.from(unmatchedFilters).filter((f) => f.type === 'tool' || f.type === 'resource'); - if (unmatched.length > 0) { - throw new Error( - `The following filters did not match any endpoints: ${unmatched - .map((f) => `${f.type}=${f.value}`) - .join(', ')}`, - ); - } - - return filtered; -} - -function match({ type, value }: Filter, endpoint: Endpoint): boolean { - switch (type) { - case 'resource': { - const regexStr = '^' + normalizeResource(value).replace(/\*/g, '.*') + '$'; - const regex = new RegExp(regexStr); - return regex.test(normalizeResource(endpoint.metadata.resource)); - } - case 'operation': - return endpoint.metadata.operation === value; - case 'tag': - return endpoint.metadata.tags.includes(value); - case 'tool': - return endpoint.tool.name === value; - } -} - -function normalizeResource(resource: string): string { - return resource.toLowerCase().replace(/[^a-z.*\-_]*/g, ''); -} diff --git a/packages/mcp-server/src/tools/integrations/connect-integration.ts b/packages/mcp-server/src/tools/integrations/connect-integration.ts deleted file mode 100644 index b81b02b..0000000 --- a/packages/mcp-server/src/tools/integrations/connect-integration.ts +++ /dev/null @@ -1,61 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -import { isJqError, maybeFilter } from 'hyperspell-mcp/filtering'; -import { Metadata, asErrorResult, asTextContentResult } from 'hyperspell-mcp/tools/types'; - -import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import Hyperspell from 'hyperspell'; - -export const metadata: Metadata = { - resource: 'integrations', - operation: 'read', - tags: [], - httpMethod: 'get', - httpPath: '/integrations/{integration_id}/connect', - operationId: 'connect_integration_integrations__integration_id__connect_get', -}; - -export const tool: Tool = { - name: 'connect_integration', - description: - "When using this tool, always use the `jq_filter` parameter to reduce the response size and improve performance.\n\nOnly omit if you're sure you don't need the data.\n\nGet a signed url to connect to a given integration. Use the `list_integrations` tool to find the correct `integration_id` for the required provider.\n\n# Response Schema\n```json\n{\n $ref: '#/$defs/integration_connect_response',\n $defs: {\n integration_connect_response: {\n type: 'object',\n title: 'ConnectResponse',\n properties: {\n expires_at: {\n type: 'string',\n title: 'Expires At',\n format: 'date-time'\n },\n url: {\n type: 'string',\n title: 'Url'\n }\n },\n required: [ 'expires_at',\n 'url'\n ]\n }\n }\n}\n```", - inputSchema: { - type: 'object', - properties: { - integration_id: { - type: 'string', - title: 'Integration Id', - }, - redirect_url: { - type: 'string', - title: 'Redirect Url', - }, - jq_filter: { - type: 'string', - title: 'jq Filter', - description: - 'A jq filter to apply to the response to include certain fields. Consult the output schema in the tool description to see the fields that are available.\n\nFor example: to include only the `name` field in every object of a results array, you can provide ".results[].name".\n\nFor more information, see the [jq documentation](https://jqlang.org/manual/).', - }, - }, - required: ['integration_id'], - }, - annotations: { - readOnlyHint: true, - }, -}; - -export const handler = async (client: Hyperspell, args: Record | undefined) => { - const { integration_id, jq_filter, ...body } = args as any; - try { - return asTextContentResult( - await maybeFilter(jq_filter, await client.integrations.connect(integration_id, body)), - ); - } catch (error) { - if (error instanceof Hyperspell.APIError || isJqError(error)) { - return asErrorResult(error.message); - } - throw error; - } -}; - -export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/integrations/list-integrations.ts b/packages/mcp-server/src/tools/integrations/list-integrations.ts deleted file mode 100644 index d149d0e..0000000 --- a/packages/mcp-server/src/tools/integrations/list-integrations.ts +++ /dev/null @@ -1,51 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -import { isJqError, maybeFilter } from 'hyperspell-mcp/filtering'; -import { Metadata, asErrorResult, asTextContentResult } from 'hyperspell-mcp/tools/types'; - -import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import Hyperspell from 'hyperspell'; - -export const metadata: Metadata = { - resource: 'integrations', - operation: 'read', - tags: [], - httpMethod: 'get', - httpPath: '/integrations/list', - operationId: 'list_integrations_integrations_list_get', -}; - -export const tool: Tool = { - name: 'list_integrations', - description: - "When using this tool, always use the `jq_filter` parameter to reduce the response size and improve performance.\n\nOnly omit if you're sure you don't need the data.\n\nList all available integrations\n\n# Response Schema\n```json\n{\n $ref: '#/$defs/integration_list_response',\n $defs: {\n integration_list_response: {\n type: 'object',\n title: 'IntegrationListResponse',\n properties: {\n integrations: {\n type: 'array',\n title: 'Integrations',\n items: {\n type: 'object',\n title: 'IntegrationInfo',\n properties: {\n id: {\n type: 'string',\n title: 'Id',\n description: 'The integration\\'s id'\n },\n allow_multiple_connections: {\n type: 'boolean',\n title: 'Allow Multiple Connections',\n description: 'Whether the integration allows multiple connections'\n },\n auth_provider: {\n type: 'string',\n title: 'AuthProvider',\n description: 'The integration\\'s auth provider',\n enum: [ 'nango',\n 'hyperspell',\n 'composio',\n 'whitelabel',\n 'unified'\n ]\n },\n icon: {\n type: 'string',\n title: 'Icon',\n description: 'Generate a display name from the provider by capitalizing each word.'\n },\n name: {\n type: 'string',\n title: 'Name',\n description: 'Generate a display name from the provider by capitalizing each word.'\n },\n provider: {\n type: 'string',\n title: 'DocumentProviders',\n description: 'The integration\\'s provider',\n enum: [ 'collections',\n 'vault',\n 'web_crawler',\n 'notion',\n 'slack',\n 'google_calendar',\n 'reddit',\n 'box',\n 'google_drive',\n 'airtable',\n 'algolia',\n 'amplitude',\n 'asana',\n 'ashby',\n 'bamboohr',\n 'basecamp',\n 'bubbles',\n 'calendly',\n 'confluence',\n 'clickup',\n 'datadog',\n 'deel',\n 'discord',\n 'dropbox',\n 'exa',\n 'facebook',\n 'front',\n 'github',\n 'gitlab',\n 'google_docs',\n 'google_mail',\n 'google_sheet',\n 'hubspot',\n 'jira',\n 'linear',\n 'microsoft_teams',\n 'mixpanel',\n 'monday',\n 'outlook',\n 'perplexity',\n 'rippling',\n 'salesforce',\n 'segment',\n 'todoist',\n 'twitter',\n 'zoom'\n ]\n }\n },\n required: [ 'id',\n 'allow_multiple_connections',\n 'auth_provider',\n 'icon',\n 'name',\n 'provider'\n ]\n }\n }\n },\n required: [ 'integrations'\n ]\n }\n }\n}\n```", - inputSchema: { - type: 'object', - properties: { - jq_filter: { - type: 'string', - title: 'jq Filter', - description: - 'A jq filter to apply to the response to include certain fields. Consult the output schema in the tool description to see the fields that are available.\n\nFor example: to include only the `name` field in every object of a results array, you can provide ".results[].name".\n\nFor more information, see the [jq documentation](https://jqlang.org/manual/).', - }, - }, - required: [], - }, - annotations: { - readOnlyHint: true, - }, -}; - -export const handler = async (client: Hyperspell, args: Record | undefined) => { - const { jq_filter } = args as any; - try { - return asTextContentResult(await maybeFilter(jq_filter, await client.integrations.list())); - } catch (error) { - if (error instanceof Hyperspell.APIError || isJqError(error)) { - return asErrorResult(error.message); - } - throw error; - } -}; - -export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/memories/add-memory.ts b/packages/mcp-server/src/tools/memories/add-memory.ts deleted file mode 100644 index b65704c..0000000 --- a/packages/mcp-server/src/tools/memories/add-memory.ts +++ /dev/null @@ -1,84 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -import { isJqError, maybeFilter } from 'hyperspell-mcp/filtering'; -import { Metadata, asErrorResult, asTextContentResult } from 'hyperspell-mcp/tools/types'; - -import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import Hyperspell from 'hyperspell'; - -export const metadata: Metadata = { - resource: 'memories', - operation: 'write', - tags: [], - httpMethod: 'post', - httpPath: '/memories/add', - operationId: 'add_memories_add_post', -}; - -export const tool: Tool = { - name: 'add_memory', - description: - "When using this tool, always use the `jq_filter` parameter to reduce the response size and improve performance.\n\nOnly omit if you're sure you don't need the data.\n\nThis tool lets you add text, markdown, or JSON to the Hyperspell index so it can be searched later. It will return the `source` and `resource_id` that can be used to later retrieve the processed memory.\n\n# Response Schema\n```json\n{\n $ref: '#/$defs/memory_status',\n $defs: {\n memory_status: {\n type: 'object',\n title: 'DocumentStatusResponse',\n properties: {\n resource_id: {\n type: 'string',\n title: 'Resource Id'\n },\n source: {\n type: 'string',\n title: 'DocumentProviders',\n enum: [ 'collections',\n 'vault',\n 'web_crawler',\n 'notion',\n 'slack',\n 'google_calendar',\n 'reddit',\n 'box',\n 'google_drive',\n 'airtable',\n 'algolia',\n 'amplitude',\n 'asana',\n 'ashby',\n 'bamboohr',\n 'basecamp',\n 'bubbles',\n 'calendly',\n 'confluence',\n 'clickup',\n 'datadog',\n 'deel',\n 'discord',\n 'dropbox',\n 'exa',\n 'facebook',\n 'front',\n 'github',\n 'gitlab',\n 'google_docs',\n 'google_mail',\n 'google_sheet',\n 'hubspot',\n 'jira',\n 'linear',\n 'microsoft_teams',\n 'mixpanel',\n 'monday',\n 'outlook',\n 'perplexity',\n 'rippling',\n 'salesforce',\n 'segment',\n 'todoist',\n 'twitter',\n 'zoom'\n ]\n },\n status: {\n type: 'string',\n title: 'DocumentStatus',\n enum: [ 'pending',\n 'processing',\n 'completed',\n 'failed'\n ]\n }\n },\n required: [ 'resource_id',\n 'source',\n 'status'\n ]\n }\n }\n}\n```", - inputSchema: { - type: 'object', - properties: { - text: { - type: 'string', - title: 'Text', - description: 'Full text of the document.', - }, - collection: { - type: 'string', - title: 'Collection', - description: 'The collection to add the document to for easier retrieval.', - }, - date: { - type: 'string', - title: 'Date', - description: - 'Date of the document. Depending on the document, this could be the creation date or date the document was last updated (eg. for a chat transcript, this would be the date of the last message). This helps the ranking algorithm and allows you to filter by date range.', - format: 'date-time', - }, - metadata: { - type: 'object', - title: 'Metadata', - description: - 'Custom metadata for filtering. Keys must be alphanumeric with underscores, max 64 chars. Values must be string, number, or boolean.', - additionalProperties: true, - }, - resource_id: { - type: 'string', - title: 'Resource Id', - description: - 'The resource ID to add the document to. If not provided, a new resource ID will be generated. If provided, the document will be updated if it already exists.', - }, - title: { - type: 'string', - title: 'Title', - description: 'Title of the document.', - }, - jq_filter: { - type: 'string', - title: 'jq Filter', - description: - 'A jq filter to apply to the response to include certain fields. Consult the output schema in the tool description to see the fields that are available.\n\nFor example: to include only the `name` field in every object of a results array, you can provide ".results[].name".\n\nFor more information, see the [jq documentation](https://jqlang.org/manual/).', - }, - }, - required: ['text'], - }, - annotations: {}, -}; - -export const handler = async (client: Hyperspell, args: Record | undefined) => { - const { jq_filter, ...body } = args as any; - try { - return asTextContentResult(await maybeFilter(jq_filter, await client.memories.add(body))); - } catch (error) { - if (error instanceof Hyperspell.APIError || isJqError(error)) { - return asErrorResult(error.message); - } - throw error; - } -}; - -export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/memories/get-memory.ts b/packages/mcp-server/src/tools/memories/get-memory.ts deleted file mode 100644 index f61bd98..0000000 --- a/packages/mcp-server/src/tools/memories/get-memory.ts +++ /dev/null @@ -1,107 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -import { isJqError, maybeFilter } from 'hyperspell-mcp/filtering'; -import { Metadata, asErrorResult, asTextContentResult } from 'hyperspell-mcp/tools/types'; - -import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import Hyperspell from 'hyperspell'; - -export const metadata: Metadata = { - resource: 'memories', - operation: 'read', - tags: [], - httpMethod: 'get', - httpPath: '/memories/get/{source}/{resource_id}', - operationId: 'get_memory_memories_get__source___resource_id__get', -}; - -export const tool: Tool = { - name: 'get_memory', - description: - "When using this tool, always use the `jq_filter` parameter to reduce the response size and improve performance.\n\nOnly omit if you're sure you don't need the data.\n\nThis tool lets you retrieve a memory that has been previously indexed.\n\n# Response Schema\n```json\n{\n $ref: '#/$defs/memory',\n $defs: {\n memory: {\n type: 'object',\n title: 'Resource',\n properties: {\n resource_id: {\n type: 'string',\n title: 'Resource Id'\n },\n source: {\n type: 'string',\n title: 'DocumentProviders',\n enum: [ 'collections',\n 'vault',\n 'web_crawler',\n 'notion',\n 'slack',\n 'google_calendar',\n 'reddit',\n 'box',\n 'google_drive',\n 'airtable',\n 'algolia',\n 'amplitude',\n 'asana',\n 'ashby',\n 'bamboohr',\n 'basecamp',\n 'bubbles',\n 'calendly',\n 'confluence',\n 'clickup',\n 'datadog',\n 'deel',\n 'discord',\n 'dropbox',\n 'exa',\n 'facebook',\n 'front',\n 'github',\n 'gitlab',\n 'google_docs',\n 'google_mail',\n 'google_sheet',\n 'hubspot',\n 'jira',\n 'linear',\n 'microsoft_teams',\n 'mixpanel',\n 'monday',\n 'outlook',\n 'perplexity',\n 'rippling',\n 'salesforce',\n 'segment',\n 'todoist',\n 'twitter',\n 'zoom'\n ]\n },\n metadata: {\n type: 'object',\n title: 'Metadata',\n properties: {\n created_at: {\n type: 'string',\n title: 'Created At',\n format: 'date-time'\n },\n events: {\n type: 'array',\n title: 'Events',\n items: {\n type: 'object',\n title: 'Notification',\n properties: {\n message: {\n type: 'string',\n title: 'Message'\n },\n type: {\n type: 'string',\n title: 'NotificationType',\n enum: [ 'error',\n 'warning',\n 'info',\n 'success'\n ]\n },\n time: {\n type: 'string',\n title: 'Time',\n format: 'date-time'\n }\n },\n required: [ 'message',\n 'type'\n ]\n }\n },\n indexed_at: {\n type: 'string',\n title: 'Indexed At',\n format: 'date-time'\n },\n last_modified: {\n type: 'string',\n title: 'Last Modified',\n format: 'date-time'\n },\n status: {\n type: 'string',\n title: 'DocumentStatus',\n enum: [ 'pending',\n 'processing',\n 'completed',\n 'failed'\n ]\n },\n url: {\n type: 'string',\n title: 'Url'\n }\n }\n },\n score: {\n type: 'number',\n title: 'Score',\n description: 'The relevance of the resource to the query'\n },\n title: {\n type: 'string',\n title: 'Title'\n }\n },\n required: [ 'resource_id',\n 'source'\n ]\n }\n }\n}\n```", - inputSchema: { - type: 'object', - properties: { - source: { - type: 'string', - title: 'DocumentProviders', - enum: [ - 'collections', - 'vault', - 'web_crawler', - 'notion', - 'slack', - 'google_calendar', - 'reddit', - 'box', - 'google_drive', - 'airtable', - 'algolia', - 'amplitude', - 'asana', - 'ashby', - 'bamboohr', - 'basecamp', - 'bubbles', - 'calendly', - 'confluence', - 'clickup', - 'datadog', - 'deel', - 'discord', - 'dropbox', - 'exa', - 'facebook', - 'front', - 'github', - 'gitlab', - 'google_docs', - 'google_mail', - 'google_sheet', - 'hubspot', - 'jira', - 'linear', - 'microsoft_teams', - 'mixpanel', - 'monday', - 'outlook', - 'perplexity', - 'rippling', - 'salesforce', - 'segment', - 'todoist', - 'twitter', - 'zoom', - ], - }, - resource_id: { - type: 'string', - title: 'Resource Id', - }, - jq_filter: { - type: 'string', - title: 'jq Filter', - description: - 'A jq filter to apply to the response to include certain fields. Consult the output schema in the tool description to see the fields that are available.\n\nFor example: to include only the `name` field in every object of a results array, you can provide ".results[].name".\n\nFor more information, see the [jq documentation](https://jqlang.org/manual/).', - }, - }, - required: ['source', 'resource_id'], - }, - annotations: { - readOnlyHint: true, - }, -}; - -export const handler = async (client: Hyperspell, args: Record | undefined) => { - const { resource_id, jq_filter, ...body } = args as any; - try { - return asTextContentResult(await maybeFilter(jq_filter, await client.memories.get(resource_id, body))); - } catch (error) { - if (error instanceof Hyperspell.APIError || isJqError(error)) { - return asErrorResult(error.message); - } - throw error; - } -}; - -export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/memories/search.ts b/packages/mcp-server/src/tools/memories/search.ts deleted file mode 100644 index 2ae5252..0000000 --- a/packages/mcp-server/src/tools/memories/search.ts +++ /dev/null @@ -1,520 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -import { Metadata, asErrorResult, asTextContentResult } from 'hyperspell-mcp/tools/types'; - -import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import Hyperspell from 'hyperspell'; - -export const metadata: Metadata = { - resource: 'memories', - operation: 'write', - tags: [], - httpMethod: 'post', - httpPath: '/memories/query', - operationId: 'query_memories_memories_query_post', -}; - -export const tool: Tool = { - name: 'search', - description: - "Search all memories indexed by Hyperspell. Set 'answer' to true to directly answer the query, or to 'false' to simply get all memories related to the query.", - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - title: 'Query', - description: 'Query to run.', - }, - answer: { - type: 'boolean', - title: 'Answer', - description: 'If true, the query will be answered along with matching source documents.', - }, - max_results: { - type: 'integer', - title: 'Max Results', - description: 'Maximum number of results to return.', - }, - options: { - type: 'object', - title: 'QueryOptions', - description: 'Search options for the query.', - properties: { - after: { - type: 'string', - title: 'After', - description: 'Only query documents created on or after this date.', - format: 'date-time', - }, - answer_model: { - type: 'string', - title: 'AnswerModel', - description: 'Model to use for answer generation when answer=True', - enum: ['llama-3.1', 'gemma2', 'qwen-qwq', 'mistral-saba', 'llama-4-scout', 'deepseek-r1'], - }, - before: { - type: 'string', - title: 'Before', - description: 'Only query documents created before this date.', - format: 'date-time', - }, - box: { - type: 'object', - title: 'BoxSearchOptions', - description: 'Search options for Box', - properties: { - after: { - type: 'string', - title: 'After', - description: 'Only query documents created on or after this date.', - format: 'date-time', - }, - before: { - type: 'string', - title: 'Before', - description: 'Only query documents created before this date.', - format: 'date-time', - }, - filter: { - type: 'object', - title: 'Filter', - description: - "Metadata filters using MongoDB-style operators. Example: {'status': 'published', 'priority': {'$gt': 3}}", - additionalProperties: true, - }, - weight: { - type: 'number', - title: 'Weight', - description: - 'Weight of results from this source. A weight greater than 1.0 means more results from this source will be returned, a weight less than 1.0 means fewer results will be returned. This will only affect results if multiple sources are queried at the same time.', - }, - }, - }, - collections: { - type: 'object', - title: 'VaultSearchOptions', - description: 'Search options for vault', - properties: { - after: { - type: 'string', - title: 'After', - description: 'Only query documents created on or after this date.', - format: 'date-time', - }, - before: { - type: 'string', - title: 'Before', - description: 'Only query documents created before this date.', - format: 'date-time', - }, - filter: { - type: 'object', - title: 'Filter', - description: - "Metadata filters using MongoDB-style operators. Example: {'status': 'published', 'priority': {'$gt': 3}}", - additionalProperties: true, - }, - weight: { - type: 'number', - title: 'Weight', - description: - 'Weight of results from this source. A weight greater than 1.0 means more results from this source will be returned, a weight less than 1.0 means fewer results will be returned. This will only affect results if multiple sources are queried at the same time.', - }, - }, - }, - filter: { - type: 'object', - title: 'Filter', - description: - "Metadata filters using MongoDB-style operators. Example: {'status': 'published', 'priority': {'$gt': 3}}", - additionalProperties: true, - }, - google_calendar: { - type: 'object', - title: 'GoogleCalendarSearchOptions', - description: 'Search options for Google Calendar', - properties: { - after: { - type: 'string', - title: 'After', - description: 'Only query documents created on or after this date.', - format: 'date-time', - }, - before: { - type: 'string', - title: 'Before', - description: 'Only query documents created before this date.', - format: 'date-time', - }, - calendar_id: { - type: 'string', - title: 'Calendar Id', - description: - 'The ID of the calendar to search. If not provided, it will use the ID of the default calendar. You can get the list of calendars with the `/integrations/google_calendar/list` endpoint.', - }, - filter: { - type: 'object', - title: 'Filter', - description: - "Metadata filters using MongoDB-style operators. Example: {'status': 'published', 'priority': {'$gt': 3}}", - additionalProperties: true, - }, - weight: { - type: 'number', - title: 'Weight', - description: - 'Weight of results from this source. A weight greater than 1.0 means more results from this source will be returned, a weight less than 1.0 means fewer results will be returned. This will only affect results if multiple sources are queried at the same time.', - }, - }, - }, - google_drive: { - type: 'object', - title: 'GoogleDriveSearchOptions', - description: 'Search options for Google Drive', - properties: { - after: { - type: 'string', - title: 'After', - description: 'Only query documents created on or after this date.', - format: 'date-time', - }, - before: { - type: 'string', - title: 'Before', - description: 'Only query documents created before this date.', - format: 'date-time', - }, - filter: { - type: 'object', - title: 'Filter', - description: - "Metadata filters using MongoDB-style operators. Example: {'status': 'published', 'priority': {'$gt': 3}}", - additionalProperties: true, - }, - weight: { - type: 'number', - title: 'Weight', - description: - 'Weight of results from this source. A weight greater than 1.0 means more results from this source will be returned, a weight less than 1.0 means fewer results will be returned. This will only affect results if multiple sources are queried at the same time.', - }, - }, - }, - google_mail: { - type: 'object', - title: 'GmailSearchOptions', - description: 'Search options for Gmail', - properties: { - after: { - type: 'string', - title: 'After', - description: 'Only query documents created on or after this date.', - format: 'date-time', - }, - before: { - type: 'string', - title: 'Before', - description: 'Only query documents created before this date.', - format: 'date-time', - }, - filter: { - type: 'object', - title: 'Filter', - description: - "Metadata filters using MongoDB-style operators. Example: {'status': 'published', 'priority': {'$gt': 3}}", - additionalProperties: true, - }, - label_ids: { - type: 'array', - title: 'Label Ids', - description: - "List of label IDs to filter messages (e.g., ['INBOX', 'SENT', 'DRAFT']). Multiple labels are combined with OR logic - messages matching ANY specified label will be returned. If empty, no label filtering is applied (searches all accessible messages).", - items: { - type: 'string', - }, - }, - weight: { - type: 'number', - title: 'Weight', - description: - 'Weight of results from this source. A weight greater than 1.0 means more results from this source will be returned, a weight less than 1.0 means fewer results will be returned. This will only affect results if multiple sources are queried at the same time.', - }, - }, - }, - max_results: { - type: 'integer', - title: 'Max Results', - description: 'Maximum number of results to return.', - }, - notion: { - type: 'object', - title: 'NotionSearchOptions', - description: 'Search options for Notion', - properties: { - after: { - type: 'string', - title: 'After', - description: 'Only query documents created on or after this date.', - format: 'date-time', - }, - before: { - type: 'string', - title: 'Before', - description: 'Only query documents created before this date.', - format: 'date-time', - }, - filter: { - type: 'object', - title: 'Filter', - description: - "Metadata filters using MongoDB-style operators. Example: {'status': 'published', 'priority': {'$gt': 3}}", - additionalProperties: true, - }, - notion_page_ids: { - type: 'array', - title: 'Notion Page Ids', - description: - 'List of Notion page IDs to search. If not provided, all pages in the workspace will be searched.', - items: { - type: 'string', - }, - }, - weight: { - type: 'number', - title: 'Weight', - description: - 'Weight of results from this source. A weight greater than 1.0 means more results from this source will be returned, a weight less than 1.0 means fewer results will be returned. This will only affect results if multiple sources are queried at the same time.', - }, - }, - }, - reddit: { - type: 'object', - title: 'RedditSearchOptions', - description: 'Search options for Reddit', - properties: { - after: { - type: 'string', - title: 'After', - description: 'Only query documents created on or after this date.', - format: 'date-time', - }, - before: { - type: 'string', - title: 'Before', - description: 'Only query documents created before this date.', - format: 'date-time', - }, - filter: { - type: 'object', - title: 'Filter', - description: - "Metadata filters using MongoDB-style operators. Example: {'status': 'published', 'priority': {'$gt': 3}}", - additionalProperties: true, - }, - period: { - type: 'string', - title: 'Period', - description: "The time period to search. Defaults to 'month'.", - enum: ['hour', 'day', 'week', 'month', 'year', 'all'], - }, - sort: { - type: 'string', - title: 'Sort', - description: "The sort order of the posts. Defaults to 'relevance'.", - enum: ['relevance', 'new', 'hot', 'top', 'comments'], - }, - subreddit: { - type: 'string', - title: 'Subreddit', - description: - 'The subreddit to search. If not provided, the query will be searched for in all subreddits.', - }, - weight: { - type: 'number', - title: 'Weight', - description: - 'Weight of results from this source. A weight greater than 1.0 means more results from this source will be returned, a weight less than 1.0 means fewer results will be returned. This will only affect results if multiple sources are queried at the same time.', - }, - }, - }, - slack: { - type: 'object', - title: 'SlackSearchOptions', - description: 'Search options for Slack', - properties: { - after: { - type: 'string', - title: 'After', - description: 'Only query documents created on or after this date.', - format: 'date-time', - }, - before: { - type: 'string', - title: 'Before', - description: 'Only query documents created before this date.', - format: 'date-time', - }, - channels: { - type: 'array', - title: 'Channels', - description: 'List of Slack channels to include (by id, name, or #name).', - items: { - type: 'string', - }, - }, - exclude_archived: { - type: 'boolean', - title: 'Exclude Archived', - description: "If set, pass 'exclude_archived' to Slack. If None, omit the param.", - }, - filter: { - type: 'object', - title: 'Filter', - description: - "Metadata filters using MongoDB-style operators. Example: {'status': 'published', 'priority': {'$gt': 3}}", - additionalProperties: true, - }, - include_dms: { - type: 'boolean', - title: 'Include Dms', - description: 'Include direct messages (im) when listing conversations.', - }, - include_group_dms: { - type: 'boolean', - title: 'Include Group Dms', - description: 'Include group DMs (mpim) when listing conversations.', - }, - include_private: { - type: 'boolean', - title: 'Include Private', - description: - "Include private channels when constructing Slack 'types'. Defaults to False to preserve existing cassette query params.", - }, - weight: { - type: 'number', - title: 'Weight', - description: - 'Weight of results from this source. A weight greater than 1.0 means more results from this source will be returned, a weight less than 1.0 means fewer results will be returned. This will only affect results if multiple sources are queried at the same time.', - }, - }, - }, - web_crawler: { - type: 'object', - title: 'WebCrawlerSearchOptions', - description: 'Search options for Web Crawler', - properties: { - after: { - type: 'string', - title: 'After', - description: 'Only query documents created on or after this date.', - format: 'date-time', - }, - before: { - type: 'string', - title: 'Before', - description: 'Only query documents created before this date.', - format: 'date-time', - }, - filter: { - type: 'object', - title: 'Filter', - description: - "Metadata filters using MongoDB-style operators. Example: {'status': 'published', 'priority': {'$gt': 3}}", - additionalProperties: true, - }, - max_depth: { - type: 'integer', - title: 'Max Depth', - description: 'Maximum depth to crawl from the starting URL', - }, - url: { - type: 'string', - title: 'Url', - description: 'The URL to crawl', - }, - weight: { - type: 'number', - title: 'Weight', - description: - 'Weight of results from this source. A weight greater than 1.0 means more results from this source will be returned, a weight less than 1.0 means fewer results will be returned. This will only affect results if multiple sources are queried at the same time.', - }, - }, - }, - }, - }, - sources: { - type: 'array', - title: 'Sources', - description: 'Only query documents from these sources.', - items: { - type: 'string', - title: 'DocumentProviders', - enum: [ - 'collections', - 'vault', - 'web_crawler', - 'notion', - 'slack', - 'google_calendar', - 'reddit', - 'box', - 'google_drive', - 'airtable', - 'algolia', - 'amplitude', - 'asana', - 'ashby', - 'bamboohr', - 'basecamp', - 'bubbles', - 'calendly', - 'confluence', - 'clickup', - 'datadog', - 'deel', - 'discord', - 'dropbox', - 'exa', - 'facebook', - 'front', - 'github', - 'gitlab', - 'google_docs', - 'google_mail', - 'google_sheet', - 'hubspot', - 'jira', - 'linear', - 'microsoft_teams', - 'mixpanel', - 'monday', - 'outlook', - 'perplexity', - 'rippling', - 'salesforce', - 'segment', - 'todoist', - 'twitter', - 'zoom', - ], - }, - }, - }, - required: ['query'], - }, - annotations: {}, -}; - -export const handler = async (client: Hyperspell, args: Record | undefined) => { - const body = args as any; - try { - return asTextContentResult(await client.memories.search(body)); - } catch (error) { - if (error instanceof Hyperspell.APIError) { - return asErrorResult(error.message); - } - throw error; - } -}; - -export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/memories/update-memory.ts b/packages/mcp-server/src/tools/memories/update-memory.ts deleted file mode 100644 index c692b75..0000000 --- a/packages/mcp-server/src/tools/memories/update-memory.ts +++ /dev/null @@ -1,159 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -import { isJqError, maybeFilter } from 'hyperspell-mcp/filtering'; -import { Metadata, asErrorResult, asTextContentResult } from 'hyperspell-mcp/tools/types'; - -import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import Hyperspell from 'hyperspell'; - -export const metadata: Metadata = { - resource: 'memories', - operation: 'write', - tags: [], - httpMethod: 'post', - httpPath: '/memories/update/{source}/{resource_id}', - operationId: 'update_memories_update__source___resource_id__post', -}; - -export const tool: Tool = { - name: 'update_memory', - description: - "When using this tool, always use the `jq_filter` parameter to reduce the response size and improve performance.\n\nOnly omit if you're sure you don't need the data.\n\nThis tool lets you update memory in Hyperspell.\n\n# Response Schema\n```json\n{\n $ref: '#/$defs/memory_status',\n $defs: {\n memory_status: {\n type: 'object',\n title: 'DocumentStatusResponse',\n properties: {\n resource_id: {\n type: 'string',\n title: 'Resource Id'\n },\n source: {\n type: 'string',\n title: 'DocumentProviders',\n enum: [ 'collections',\n 'vault',\n 'web_crawler',\n 'notion',\n 'slack',\n 'google_calendar',\n 'reddit',\n 'box',\n 'google_drive',\n 'airtable',\n 'algolia',\n 'amplitude',\n 'asana',\n 'ashby',\n 'bamboohr',\n 'basecamp',\n 'bubbles',\n 'calendly',\n 'confluence',\n 'clickup',\n 'datadog',\n 'deel',\n 'discord',\n 'dropbox',\n 'exa',\n 'facebook',\n 'front',\n 'github',\n 'gitlab',\n 'google_docs',\n 'google_mail',\n 'google_sheet',\n 'hubspot',\n 'jira',\n 'linear',\n 'microsoft_teams',\n 'mixpanel',\n 'monday',\n 'outlook',\n 'perplexity',\n 'rippling',\n 'salesforce',\n 'segment',\n 'todoist',\n 'twitter',\n 'zoom'\n ]\n },\n status: {\n type: 'string',\n title: 'DocumentStatus',\n enum: [ 'pending',\n 'processing',\n 'completed',\n 'failed'\n ]\n }\n },\n required: [ 'resource_id',\n 'source',\n 'status'\n ]\n }\n }\n}\n```", - inputSchema: { - type: 'object', - properties: { - source: { - type: 'string', - title: 'DocumentProviders', - enum: [ - 'collections', - 'vault', - 'web_crawler', - 'notion', - 'slack', - 'google_calendar', - 'reddit', - 'box', - 'google_drive', - 'airtable', - 'algolia', - 'amplitude', - 'asana', - 'ashby', - 'bamboohr', - 'basecamp', - 'bubbles', - 'calendly', - 'confluence', - 'clickup', - 'datadog', - 'deel', - 'discord', - 'dropbox', - 'exa', - 'facebook', - 'front', - 'github', - 'gitlab', - 'google_docs', - 'google_mail', - 'google_sheet', - 'hubspot', - 'jira', - 'linear', - 'microsoft_teams', - 'mixpanel', - 'monday', - 'outlook', - 'perplexity', - 'rippling', - 'salesforce', - 'segment', - 'todoist', - 'twitter', - 'zoom', - ], - }, - resource_id: { - type: 'string', - title: 'Resource Id', - }, - collection: { - anyOf: [ - { - type: 'string', - }, - { - type: 'object', - additionalProperties: true, - }, - ], - title: 'Collection', - description: 'The collection to move the document to. Set to null to remove the collection.', - }, - metadata: { - anyOf: [ - { - type: 'object', - additionalProperties: true, - }, - { - type: 'object', - additionalProperties: true, - }, - ], - title: 'Metadata', - description: - 'Custom metadata for filtering. Keys must be alphanumeric with underscores, max 64 chars. Values must be string, number, or boolean. Will be merged with existing metadata.', - }, - text: { - anyOf: [ - { - type: 'string', - }, - { - type: 'object', - additionalProperties: true, - }, - ], - title: 'Text', - description: 'Full text of the document. If provided, the document will be re-indexed.', - }, - title: { - anyOf: [ - { - type: 'string', - }, - { - type: 'object', - additionalProperties: true, - }, - ], - title: 'Title', - description: 'Title of the document.', - }, - jq_filter: { - type: 'string', - title: 'jq Filter', - description: - 'A jq filter to apply to the response to include certain fields. Consult the output schema in the tool description to see the fields that are available.\n\nFor example: to include only the `name` field in every object of a results array, you can provide ".results[].name".\n\nFor more information, see the [jq documentation](https://jqlang.org/manual/).', - }, - }, - required: ['source', 'resource_id'], - }, - annotations: {}, -}; - -export const handler = async (client: Hyperspell, args: Record | undefined) => { - const { resource_id, jq_filter, ...body } = args as any; - try { - return asTextContentResult(await maybeFilter(jq_filter, await client.memories.update(resource_id, body))); - } catch (error) { - if (error instanceof Hyperspell.APIError || isJqError(error)) { - return asErrorResult(error.message); - } - throw error; - } -}; - -export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/memories/upload-file.ts b/packages/mcp-server/src/tools/memories/upload-file.ts deleted file mode 100644 index c57b9cf..0000000 --- a/packages/mcp-server/src/tools/memories/upload-file.ts +++ /dev/null @@ -1,65 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -import { isJqError, maybeFilter } from 'hyperspell-mcp/filtering'; -import { Metadata, asErrorResult, asTextContentResult } from 'hyperspell-mcp/tools/types'; - -import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import Hyperspell from 'hyperspell'; - -export const metadata: Metadata = { - resource: 'memories', - operation: 'write', - tags: [], - httpMethod: 'post', - httpPath: '/memories/upload', - operationId: 'upload_memories_upload_post', -}; - -export const tool: Tool = { - name: 'upload_file', - description: - "When using this tool, always use the `jq_filter` parameter to reduce the response size and improve performance.\n\nOnly omit if you're sure you don't need the data.\n\nThis tool lets you upload a file to the Hyperspell index. It will return the `source` and `resource_id` that can be used to later retrieve the processed memory.\n\n# Response Schema\n```json\n{\n $ref: '#/$defs/memory_status',\n $defs: {\n memory_status: {\n type: 'object',\n title: 'DocumentStatusResponse',\n properties: {\n resource_id: {\n type: 'string',\n title: 'Resource Id'\n },\n source: {\n type: 'string',\n title: 'DocumentProviders',\n enum: [ 'collections',\n 'vault',\n 'web_crawler',\n 'notion',\n 'slack',\n 'google_calendar',\n 'reddit',\n 'box',\n 'google_drive',\n 'airtable',\n 'algolia',\n 'amplitude',\n 'asana',\n 'ashby',\n 'bamboohr',\n 'basecamp',\n 'bubbles',\n 'calendly',\n 'confluence',\n 'clickup',\n 'datadog',\n 'deel',\n 'discord',\n 'dropbox',\n 'exa',\n 'facebook',\n 'front',\n 'github',\n 'gitlab',\n 'google_docs',\n 'google_mail',\n 'google_sheet',\n 'hubspot',\n 'jira',\n 'linear',\n 'microsoft_teams',\n 'mixpanel',\n 'monday',\n 'outlook',\n 'perplexity',\n 'rippling',\n 'salesforce',\n 'segment',\n 'todoist',\n 'twitter',\n 'zoom'\n ]\n },\n status: {\n type: 'string',\n title: 'DocumentStatus',\n enum: [ 'pending',\n 'processing',\n 'completed',\n 'failed'\n ]\n }\n },\n required: [ 'resource_id',\n 'source',\n 'status'\n ]\n }\n }\n}\n```", - inputSchema: { - type: 'object', - properties: { - file: { - type: 'string', - title: 'File', - description: 'The file to ingest.', - }, - collection: { - type: 'string', - title: 'Collection', - description: 'The collection to add the document to.', - }, - metadata: { - type: 'string', - title: 'Metadata', - description: - 'Custom metadata as JSON string for filtering. Keys must be alphanumeric with underscores, max 64 chars. Values must be string, number, or boolean.', - }, - jq_filter: { - type: 'string', - title: 'jq Filter', - description: - 'A jq filter to apply to the response to include certain fields. Consult the output schema in the tool description to see the fields that are available.\n\nFor example: to include only the `name` field in every object of a results array, you can provide ".results[].name".\n\nFor more information, see the [jq documentation](https://jqlang.org/manual/).', - }, - }, - required: ['file'], - }, - annotations: {}, -}; - -export const handler = async (client: Hyperspell, args: Record | undefined) => { - const { jq_filter, ...body } = args as any; - try { - return asTextContentResult(await maybeFilter(jq_filter, await client.memories.upload(body))); - } catch (error) { - if (error instanceof Hyperspell.APIError || isJqError(error)) { - return asErrorResult(error.message); - } - throw error; - } -}; - -export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/types.ts b/packages/mcp-server/src/types.ts similarity index 98% rename from packages/mcp-server/src/tools/types.ts rename to packages/mcp-server/src/types.ts index 956a254..208bf61 100644 --- a/packages/mcp-server/src/tools/types.ts +++ b/packages/mcp-server/src/types.ts @@ -108,7 +108,7 @@ export type Metadata = { operationId?: string; }; -export type Endpoint = { +export type McpTool = { metadata: Metadata; tool: Tool; handler: HandlerFunction; diff --git a/packages/mcp-server/tests/compat.test.ts b/packages/mcp-server/tests/compat.test.ts deleted file mode 100644 index d6272f6..0000000 --- a/packages/mcp-server/tests/compat.test.ts +++ /dev/null @@ -1,1166 +0,0 @@ -import { - truncateToolNames, - removeTopLevelUnions, - removeAnyOf, - inlineRefs, - applyCompatibilityTransformations, - removeFormats, - findUsedDefs, -} from '../src/compat'; -import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import { JSONSchema } from '../src/compat'; -import { Endpoint } from '../src/tools'; - -describe('truncateToolNames', () => { - it('should return original names when maxLength is 0 or negative', () => { - const names = ['tool1', 'tool2', 'tool3']; - expect(truncateToolNames(names, 0)).toEqual(new Map()); - expect(truncateToolNames(names, -1)).toEqual(new Map()); - }); - - it('should return original names when all names are shorter than maxLength', () => { - const names = ['tool1', 'tool2', 'tool3']; - expect(truncateToolNames(names, 10)).toEqual(new Map()); - }); - - it('should truncate names longer than maxLength', () => { - const names = ['very-long-tool-name', 'another-long-tool-name', 'short']; - expect(truncateToolNames(names, 10)).toEqual( - new Map([ - ['very-long-tool-name', 'very-long-'], - ['another-long-tool-name', 'another-lo'], - ]), - ); - }); - - it('should handle duplicate truncated names by appending numbers', () => { - const names = ['tool-name-a', 'tool-name-b', 'tool-name-c']; - expect(truncateToolNames(names, 8)).toEqual( - new Map([ - ['tool-name-a', 'tool-na1'], - ['tool-name-b', 'tool-na2'], - ['tool-name-c', 'tool-na3'], - ]), - ); - }); -}); - -describe('removeTopLevelUnions', () => { - const createTestTool = (overrides = {}): Tool => ({ - name: 'test-tool', - description: 'Test tool', - inputSchema: { - type: 'object', - properties: {}, - }, - ...overrides, - }); - - it('should return the original tool if it has no anyOf at the top level', () => { - const tool = createTestTool({ - inputSchema: { - type: 'object', - properties: { - foo: { type: 'string' }, - }, - }, - }); - - expect(removeTopLevelUnions(tool)).toEqual([tool]); - }); - - it('should split a tool with top-level anyOf into multiple tools', () => { - const tool = createTestTool({ - name: 'union-tool', - description: 'A tool with unions', - inputSchema: { - type: 'object', - properties: { - common: { type: 'string' }, - }, - anyOf: [ - { - title: 'first variant', - description: 'Its the first variant', - properties: { - variant1: { type: 'string' }, - }, - required: ['variant1'], - }, - { - title: 'second variant', - properties: { - variant2: { type: 'number' }, - }, - required: ['variant2'], - }, - ], - }, - }); - - const result = removeTopLevelUnions(tool); - - expect(result).toEqual([ - { - name: 'union-tool_first_variant', - description: 'Its the first variant', - inputSchema: { - type: 'object', - title: 'first variant', - description: 'Its the first variant', - properties: { - common: { type: 'string' }, - variant1: { type: 'string' }, - }, - required: ['variant1'], - }, - }, - { - name: 'union-tool_second_variant', - description: 'A tool with unions', - inputSchema: { - type: 'object', - title: 'second variant', - description: 'A tool with unions', - properties: { - common: { type: 'string' }, - variant2: { type: 'number' }, - }, - required: ['variant2'], - }, - }, - ]); - }); - - it('should handle $defs and only include those used by the variant', () => { - const tool = createTestTool({ - name: 'defs-tool', - description: 'A tool with $defs', - inputSchema: { - type: 'object', - properties: { - common: { type: 'string' }, - }, - $defs: { - def1: { type: 'string', format: 'email' }, - def2: { type: 'number', minimum: 0 }, - unused: { type: 'boolean' }, - }, - anyOf: [ - { - properties: { - email: { $ref: '#/$defs/def1' }, - }, - }, - { - properties: { - count: { $ref: '#/$defs/def2' }, - }, - }, - ], - }, - }); - - const result = removeTopLevelUnions(tool); - - expect(result).toEqual([ - { - name: 'defs-tool_variant1', - description: 'A tool with $defs', - inputSchema: { - type: 'object', - description: 'A tool with $defs', - properties: { - common: { type: 'string' }, - email: { $ref: '#/$defs/def1' }, - }, - $defs: { - def1: { type: 'string', format: 'email' }, - }, - }, - }, - { - name: 'defs-tool_variant2', - description: 'A tool with $defs', - inputSchema: { - type: 'object', - description: 'A tool with $defs', - properties: { - common: { type: 'string' }, - count: { $ref: '#/$defs/def2' }, - }, - $defs: { - def2: { type: 'number', minimum: 0 }, - }, - }, - }, - ]); - }); -}); - -describe('removeAnyOf', () => { - it('should return original schema if it has no anyOf', () => { - const schema = { - type: 'object', - properties: { - foo: { type: 'string' }, - bar: { type: 'number' }, - }, - }; - - expect(removeAnyOf(schema)).toEqual(schema); - }); - - it('should remove anyOf field and use the first variant', () => { - const schema = { - type: 'object', - properties: { - common: { type: 'string' }, - }, - anyOf: [ - { - properties: { - variant1: { type: 'string' }, - }, - required: ['variant1'], - }, - { - properties: { - variant2: { type: 'number' }, - }, - required: ['variant2'], - }, - ], - }; - - const expected = { - type: 'object', - properties: { - common: { type: 'string' }, - variant1: { type: 'string' }, - }, - required: ['variant1'], - }; - - expect(removeAnyOf(schema)).toEqual(expected); - }); - - it('should recursively remove anyOf fields from nested properties', () => { - const schema = { - type: 'object', - properties: { - foo: { type: 'string' }, - nested: { - type: 'object', - properties: { - bar: { type: 'number' }, - }, - anyOf: [ - { - properties: { - option1: { type: 'boolean' }, - }, - }, - { - properties: { - option2: { type: 'array' }, - }, - }, - ], - }, - }, - }; - - const expected = { - type: 'object', - properties: { - foo: { type: 'string' }, - nested: { - type: 'object', - properties: { - bar: { type: 'number' }, - option1: { type: 'boolean' }, - }, - }, - }, - }; - - expect(removeAnyOf(schema)).toEqual(expected); - }); - - it('should handle arrays', () => { - const schema = { - type: 'object', - properties: { - items: { - type: 'array', - items: { - anyOf: [{ type: 'string' }, { type: 'number' }], - }, - }, - }, - }; - - const expected = { - type: 'object', - properties: { - items: { - type: 'array', - items: { - type: 'string', - }, - }, - }, - }; - - expect(removeAnyOf(schema)).toEqual(expected); - }); -}); - -describe('findUsedDefs', () => { - it('should handle circular references without stack overflow', () => { - const defs = { - person: { - type: 'object', - properties: { - name: { type: 'string' }, - friend: { $ref: '#/$defs/person' }, // Circular reference - }, - }, - }; - - const schema = { - type: 'object', - properties: { - user: { $ref: '#/$defs/person' }, - }, - }; - - // This should not throw a stack overflow error - expect(() => { - const result = findUsedDefs(schema, defs); - expect(result).toHaveProperty('person'); - }).not.toThrow(); - }); - - it('should handle indirect circular references without stack overflow', () => { - const defs = { - node: { - type: 'object', - properties: { - value: { type: 'string' }, - child: { $ref: '#/$defs/childNode' }, - }, - }, - childNode: { - type: 'object', - properties: { - value: { type: 'string' }, - parent: { $ref: '#/$defs/node' }, // Indirect circular reference - }, - }, - }; - - const schema = { - type: 'object', - properties: { - root: { $ref: '#/$defs/node' }, - }, - }; - - // This should not throw a stack overflow error - expect(() => { - const result = findUsedDefs(schema, defs); - expect(result).toHaveProperty('node'); - expect(result).toHaveProperty('childNode'); - }).not.toThrow(); - }); - - it('should find all used definitions in non-circular schemas', () => { - const defs = { - user: { - type: 'object', - properties: { - name: { type: 'string' }, - address: { $ref: '#/$defs/address' }, - }, - }, - address: { - type: 'object', - properties: { - street: { type: 'string' }, - city: { type: 'string' }, - }, - }, - unused: { - type: 'object', - properties: { - data: { type: 'string' }, - }, - }, - }; - - const schema = { - type: 'object', - properties: { - person: { $ref: '#/$defs/user' }, - }, - }; - - const result = findUsedDefs(schema, defs); - expect(result).toHaveProperty('user'); - expect(result).toHaveProperty('address'); - expect(result).not.toHaveProperty('unused'); - }); -}); - -describe('inlineRefs', () => { - it('should return the original schema if it does not contain $refs', () => { - const schema: JSONSchema = { - type: 'object', - properties: { - name: { type: 'string' }, - age: { type: 'number' }, - }, - }; - - expect(inlineRefs(schema)).toEqual(schema); - }); - - it('should inline simple $refs', () => { - const schema: JSONSchema = { - type: 'object', - properties: { - user: { $ref: '#/$defs/user' }, - }, - $defs: { - user: { - type: 'object', - properties: { - name: { type: 'string' }, - email: { type: 'string' }, - }, - }, - }, - }; - - const expected: JSONSchema = { - type: 'object', - properties: { - user: { - type: 'object', - properties: { - name: { type: 'string' }, - email: { type: 'string' }, - }, - }, - }, - }; - - expect(inlineRefs(schema)).toEqual(expected); - }); - - it('should inline nested $refs', () => { - const schema: JSONSchema = { - type: 'object', - properties: { - order: { $ref: '#/$defs/order' }, - }, - $defs: { - order: { - type: 'object', - properties: { - id: { type: 'string' }, - items: { type: 'array', items: { $ref: '#/$defs/item' } }, - }, - }, - item: { - type: 'object', - properties: { - product: { type: 'string' }, - quantity: { type: 'integer' }, - }, - }, - }, - }; - - const expected: JSONSchema = { - type: 'object', - properties: { - order: { - type: 'object', - properties: { - id: { type: 'string' }, - items: { - type: 'array', - items: { - type: 'object', - properties: { - product: { type: 'string' }, - quantity: { type: 'integer' }, - }, - }, - }, - }, - }, - }, - }; - - expect(inlineRefs(schema)).toEqual(expected); - }); - - it('should handle circular references by removing the circular part', () => { - const schema: JSONSchema = { - type: 'object', - properties: { - person: { $ref: '#/$defs/person' }, - }, - $defs: { - person: { - type: 'object', - properties: { - name: { type: 'string' }, - friend: { $ref: '#/$defs/person' }, // Circular reference - }, - }, - }, - }; - - const expected: JSONSchema = { - type: 'object', - properties: { - person: { - type: 'object', - properties: { - name: { type: 'string' }, - // friend property is removed to break the circular reference - }, - }, - }, - }; - - expect(inlineRefs(schema)).toEqual(expected); - }); - - it('should handle indirect circular references', () => { - const schema: JSONSchema = { - type: 'object', - properties: { - node: { $ref: '#/$defs/node' }, - }, - $defs: { - node: { - type: 'object', - properties: { - value: { type: 'string' }, - child: { $ref: '#/$defs/childNode' }, - }, - }, - childNode: { - type: 'object', - properties: { - value: { type: 'string' }, - parent: { $ref: '#/$defs/node' }, // Circular reference through childNode - }, - }, - }, - }; - - const expected: JSONSchema = { - type: 'object', - properties: { - node: { - type: 'object', - properties: { - value: { type: 'string' }, - child: { - type: 'object', - properties: { - value: { type: 'string' }, - // parent property is removed to break the circular reference - }, - }, - }, - }, - }, - }; - - expect(inlineRefs(schema)).toEqual(expected); - }); - - it('should preserve other properties when inlining references', () => { - const schema: JSONSchema = { - type: 'object', - properties: { - address: { $ref: '#/$defs/address', description: 'User address' }, - }, - $defs: { - address: { - type: 'object', - properties: { - street: { type: 'string' }, - city: { type: 'string' }, - }, - required: ['street'], - }, - }, - }; - - const expected: JSONSchema = { - type: 'object', - properties: { - address: { - type: 'object', - description: 'User address', - properties: { - street: { type: 'string' }, - city: { type: 'string' }, - }, - required: ['street'], - }, - }, - }; - - expect(inlineRefs(schema)).toEqual(expected); - }); -}); - -describe('removeFormats', () => { - it('should return original schema if formats capability is true', () => { - const schema = { - type: 'object', - properties: { - date: { type: 'string', description: 'A date field', format: 'date' }, - email: { type: 'string', description: 'An email field', format: 'email' }, - }, - }; - - expect(removeFormats(schema, true)).toEqual(schema); - }); - - it('should move format to description when formats capability is false', () => { - const schema = { - type: 'object', - properties: { - date: { type: 'string', description: 'A date field', format: 'date' }, - email: { type: 'string', description: 'An email field', format: 'email' }, - }, - }; - - const expected = { - type: 'object', - properties: { - date: { type: 'string', description: 'A date field (format: "date")' }, - email: { type: 'string', description: 'An email field (format: "email")' }, - }, - }; - - expect(removeFormats(schema, false)).toEqual(expected); - }); - - it('should handle properties without description', () => { - const schema = { - type: 'object', - properties: { - date: { type: 'string', format: 'date' }, - }, - }; - - const expected = { - type: 'object', - properties: { - date: { type: 'string', description: '(format: "date")' }, - }, - }; - - expect(removeFormats(schema, false)).toEqual(expected); - }); - - it('should handle nested properties', () => { - const schema = { - type: 'object', - properties: { - user: { - type: 'object', - properties: { - created_at: { type: 'string', description: 'Creation date', format: 'date-time' }, - }, - }, - }, - }; - - const expected = { - type: 'object', - properties: { - user: { - type: 'object', - properties: { - created_at: { type: 'string', description: 'Creation date (format: "date-time")' }, - }, - }, - }, - }; - - expect(removeFormats(schema, false)).toEqual(expected); - }); - - it('should handle arrays of objects', () => { - const schema = { - type: 'object', - properties: { - dates: { - type: 'array', - items: { - type: 'object', - properties: { - start: { type: 'string', description: 'Start date', format: 'date' }, - end: { type: 'string', description: 'End date', format: 'date' }, - }, - }, - }, - }, - }; - - const expected = { - type: 'object', - properties: { - dates: { - type: 'array', - items: { - type: 'object', - properties: { - start: { type: 'string', description: 'Start date (format: "date")' }, - end: { type: 'string', description: 'End date (format: "date")' }, - }, - }, - }, - }, - }; - - expect(removeFormats(schema, false)).toEqual(expected); - }); - - it('should handle schemas with $defs', () => { - const schema = { - type: 'object', - properties: { - date: { type: 'string', description: 'A date field', format: 'date' }, - }, - $defs: { - timestamp: { - type: 'string', - description: 'A timestamp field', - format: 'date-time', - }, - }, - }; - - const expected = { - type: 'object', - properties: { - date: { type: 'string', description: 'A date field (format: "date")' }, - }, - $defs: { - timestamp: { - type: 'string', - description: 'A timestamp field (format: "date-time")', - }, - }, - }; - - expect(removeFormats(schema, false)).toEqual(expected); - }); -}); - -describe('applyCompatibilityTransformations', () => { - const createTestTool = (name: string, overrides = {}): Tool => ({ - name, - description: 'Test tool', - inputSchema: { - type: 'object', - properties: {}, - }, - ...overrides, - }); - - const createTestEndpoint = (tool: Tool): Endpoint => ({ - tool, - handler: jest.fn(), - metadata: { - resource: 'test', - operation: 'read' as const, - tags: [], - }, - }); - - it('should not modify endpoints when all capabilities are enabled', () => { - const tool = createTestTool('test-tool'); - const endpoints = [createTestEndpoint(tool)]; - - const capabilities = { - topLevelUnions: true, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: undefined, - }; - - const transformed = applyCompatibilityTransformations(endpoints, capabilities); - expect(transformed).toEqual(endpoints); - }); - - it('should split tools with top-level unions when topLevelUnions is disabled', () => { - const tool = createTestTool('union-tool', { - inputSchema: { - type: 'object', - properties: { - common: { type: 'string' }, - }, - anyOf: [ - { - title: 'first variant', - properties: { - variant1: { type: 'string' }, - }, - }, - { - title: 'second variant', - properties: { - variant2: { type: 'number' }, - }, - }, - ], - }, - }); - - const endpoints = [createTestEndpoint(tool)]; - - const capabilities = { - topLevelUnions: false, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: undefined, - }; - - const transformed = applyCompatibilityTransformations(endpoints, capabilities); - expect(transformed.length).toBe(2); - expect(transformed[0]!.tool.name).toBe('union-tool_first_variant'); - expect(transformed[1]!.tool.name).toBe('union-tool_second_variant'); - }); - - it('should handle variants without titles in removeTopLevelUnions', () => { - const tool = createTestTool('union-tool', { - inputSchema: { - type: 'object', - properties: { - common: { type: 'string' }, - }, - anyOf: [ - { - properties: { - variant1: { type: 'string' }, - }, - }, - { - properties: { - variant2: { type: 'number' }, - }, - }, - ], - }, - }); - - const endpoints = [createTestEndpoint(tool)]; - - const capabilities = { - topLevelUnions: false, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: undefined, - }; - - const transformed = applyCompatibilityTransformations(endpoints, capabilities); - expect(transformed.length).toBe(2); - expect(transformed[0]!.tool.name).toBe('union-tool_variant1'); - expect(transformed[1]!.tool.name).toBe('union-tool_variant2'); - }); - - it('should truncate tool names when toolNameLength is set', () => { - const tools = [ - createTestTool('very-long-tool-name-that-exceeds-limit'), - createTestTool('another-long-tool-name-to-truncate'), - createTestTool('short-name'), - ]; - - const endpoints = tools.map(createTestEndpoint); - - const capabilities = { - topLevelUnions: true, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: 20, - }; - - const transformed = applyCompatibilityTransformations(endpoints, capabilities); - expect(transformed[0]!.tool.name).toBe('very-long-tool-name-'); - expect(transformed[1]!.tool.name).toBe('another-long-tool-na'); - expect(transformed[2]!.tool.name).toBe('short-name'); - }); - - it('should inline refs when refs capability is disabled', () => { - const tool = createTestTool('ref-tool', { - inputSchema: { - type: 'object', - properties: { - user: { $ref: '#/$defs/user' }, - }, - $defs: { - user: { - type: 'object', - properties: { - name: { type: 'string' }, - email: { type: 'string' }, - }, - }, - }, - }, - }); - - const endpoints = [createTestEndpoint(tool)]; - - const capabilities = { - topLevelUnions: true, - validJson: true, - refs: false, - unions: true, - formats: true, - toolNameLength: undefined, - }; - - const transformed = applyCompatibilityTransformations(endpoints, capabilities); - const schema = transformed[0]!.tool.inputSchema as JSONSchema; - expect(schema.$defs).toBeUndefined(); - - if (schema.properties) { - expect(schema.properties['user']).toEqual({ - type: 'object', - properties: { - name: { type: 'string' }, - email: { type: 'string' }, - }, - }); - } - }); - - it('should preserve external refs when inlining', () => { - const tool = createTestTool('ref-tool', { - inputSchema: { - type: 'object', - properties: { - internal: { $ref: '#/$defs/internal' }, - external: { $ref: 'https://example.com/schemas/external.json' }, - }, - $defs: { - internal: { - type: 'object', - properties: { - name: { type: 'string' }, - }, - }, - }, - }, - }); - - const endpoints = [createTestEndpoint(tool)]; - - const capabilities = { - topLevelUnions: true, - validJson: true, - refs: false, - unions: true, - formats: true, - toolNameLength: undefined, - }; - - const transformed = applyCompatibilityTransformations(endpoints, capabilities); - const schema = transformed[0]!.tool.inputSchema as JSONSchema; - - if (schema.properties) { - expect(schema.properties['internal']).toEqual({ - type: 'object', - properties: { - name: { type: 'string' }, - }, - }); - expect(schema.properties['external']).toEqual({ - $ref: 'https://example.com/schemas/external.json', - }); - } - }); - - it('should remove anyOf fields when unions capability is disabled', () => { - const tool = createTestTool('union-tool', { - inputSchema: { - type: 'object', - properties: { - field: { - anyOf: [{ type: 'string' }, { type: 'number' }], - }, - }, - }, - }); - - const endpoints = [createTestEndpoint(tool)]; - - const capabilities = { - topLevelUnions: true, - validJson: true, - refs: true, - unions: false, - formats: true, - toolNameLength: undefined, - }; - - const transformed = applyCompatibilityTransformations(endpoints, capabilities); - const schema = transformed[0]!.tool.inputSchema as JSONSchema; - - if (schema.properties && schema.properties['field']) { - const field = schema.properties['field']; - expect(field.anyOf).toBeUndefined(); - expect(field.type).toBe('string'); - } - }); - - it('should correctly combine topLevelUnions and toolNameLength transformations', () => { - const tool = createTestTool('very-long-union-tool-name', { - inputSchema: { - type: 'object', - properties: { - common: { type: 'string' }, - }, - anyOf: [ - { - title: 'first variant', - properties: { - variant1: { type: 'string' }, - }, - }, - { - title: 'second variant', - properties: { - variant2: { type: 'number' }, - }, - }, - ], - }, - }); - - const endpoints = [createTestEndpoint(tool)]; - - const capabilities = { - topLevelUnions: false, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: 20, - }; - - const transformed = applyCompatibilityTransformations(endpoints, capabilities); - expect(transformed.length).toBe(2); - - // Both names should be truncated because they exceed 20 characters - expect(transformed[0]!.tool.name).toBe('very-long-union-too1'); - expect(transformed[1]!.tool.name).toBe('very-long-union-too2'); - }); - - it('should correctly combine refs and unions transformations', () => { - const tool = createTestTool('complex-tool', { - inputSchema: { - type: 'object', - properties: { - user: { $ref: '#/$defs/user' }, - }, - $defs: { - user: { - type: 'object', - properties: { - preference: { - anyOf: [{ type: 'string' }, { type: 'number' }], - }, - }, - }, - }, - }, - }); - - const endpoints = [createTestEndpoint(tool)]; - - const capabilities = { - topLevelUnions: true, - validJson: true, - refs: false, - unions: false, - formats: true, - toolNameLength: undefined, - }; - - const transformed = applyCompatibilityTransformations(endpoints, capabilities); - const schema = transformed[0]!.tool.inputSchema as JSONSchema; - - // Refs should be inlined - expect(schema.$defs).toBeUndefined(); - - // Safely access nested properties - if (schema.properties && schema.properties['user']) { - const user = schema.properties['user']; - // User should be inlined - expect(user.type).toBe('object'); - - // AnyOf in the inlined user.preference should be removed - if (user.properties && user.properties['preference']) { - const preference = user.properties['preference']; - expect(preference.anyOf).toBeUndefined(); - expect(preference.type).toBe('string'); - } - } - }); - - it('should handle formats capability being false', () => { - const tool = createTestTool('format-tool', { - inputSchema: { - type: 'object', - properties: { - date: { type: 'string', description: 'A date', format: 'date' }, - }, - }, - }); - - const endpoints = [createTestEndpoint(tool)]; - - const capabilities = { - topLevelUnions: true, - validJson: true, - refs: true, - unions: true, - formats: false, - toolNameLength: undefined, - }; - - const transformed = applyCompatibilityTransformations(endpoints, capabilities); - const schema = transformed[0]!.tool.inputSchema as JSONSchema; - - if (schema.properties && schema.properties['date']) { - const dateField = schema.properties['date']; - expect(dateField['format']).toBeUndefined(); - expect(dateField['description']).toBe('A date (format: "date")'); - } - }); -}); diff --git a/packages/mcp-server/tests/dynamic-tools.test.ts b/packages/mcp-server/tests/dynamic-tools.test.ts deleted file mode 100644 index 08963af..0000000 --- a/packages/mcp-server/tests/dynamic-tools.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { dynamicTools } from '../src/dynamic-tools'; -import { Endpoint } from '../src/tools'; - -describe('dynamicTools', () => { - const fakeClient = {} as any; - - const endpoints: Endpoint[] = [ - makeEndpoint('test_read_endpoint', 'test_resource', 'read', ['test']), - makeEndpoint('test_write_endpoint', 'test_resource', 'write', ['test']), - makeEndpoint('user_endpoint', 'user', 'read', ['user', 'admin']), - makeEndpoint('admin_endpoint', 'admin', 'write', ['admin']), - ]; - - const tools = dynamicTools(endpoints); - - const toolsMap = { - list_api_endpoints: toolOrError('list_api_endpoints'), - get_api_endpoint_schema: toolOrError('get_api_endpoint_schema'), - invoke_api_endpoint: toolOrError('invoke_api_endpoint'), - }; - - describe('list_api_endpoints', () => { - it('should return all endpoints when no search query is provided', async () => { - const content = await toolsMap.list_api_endpoints.handler(fakeClient, {}); - const result = JSON.parse(content.content[0].text); - - expect(result.tools).toHaveLength(endpoints.length); - expect(result.tools.map((t: { name: string }) => t.name)).toContain('test_read_endpoint'); - expect(result.tools.map((t: { name: string }) => t.name)).toContain('test_write_endpoint'); - expect(result.tools.map((t: { name: string }) => t.name)).toContain('user_endpoint'); - expect(result.tools.map((t: { name: string }) => t.name)).toContain('admin_endpoint'); - }); - - it('should filter endpoints by name', async () => { - const content = await toolsMap.list_api_endpoints.handler(fakeClient, { search_query: 'user' }); - const result = JSON.parse(content.content[0].text); - - expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('user_endpoint'); - }); - - it('should filter endpoints by resource', async () => { - const content = await toolsMap.list_api_endpoints.handler(fakeClient, { search_query: 'admin' }); - const result = JSON.parse(content.content[0].text); - - expect(result.tools.some((t: { resource: string }) => t.resource === 'admin')).toBeTruthy(); - }); - - it('should filter endpoints by tag', async () => { - const content = await toolsMap.list_api_endpoints.handler(fakeClient, { search_query: 'admin' }); - const result = JSON.parse(content.content[0].text); - - expect(result.tools.some((t: { tags: string[] }) => t.tags.includes('admin'))).toBeTruthy(); - }); - - it('should be case insensitive in search', async () => { - const content = await toolsMap.list_api_endpoints.handler(fakeClient, { search_query: 'ADMIN' }); - const result = JSON.parse(content.content[0].text); - - expect(result.tools.length).toBe(2); - result.tools.forEach((tool: { name: string; resource: string; tags: string[] }) => { - expect( - tool.name.toLowerCase().includes('admin') || - tool.resource.toLowerCase().includes('admin') || - tool.tags.some((tag: string) => tag.toLowerCase().includes('admin')), - ).toBeTruthy(); - }); - }); - - it('should filter endpoints by description', async () => { - const content = await toolsMap.list_api_endpoints.handler(fakeClient, { - search_query: 'Test endpoint for user_endpoint', - }); - const result = JSON.parse(content.content[0].text); - - expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('user_endpoint'); - expect(result.tools[0].description).toBe('Test endpoint for user_endpoint'); - }); - - it('should filter endpoints by partial description match', async () => { - const content = await toolsMap.list_api_endpoints.handler(fakeClient, { - search_query: 'endpoint for user', - }); - const result = JSON.parse(content.content[0].text); - - expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('user_endpoint'); - }); - }); - - describe('get_api_endpoint_schema', () => { - it('should return schema for existing endpoint', async () => { - const content = await toolsMap.get_api_endpoint_schema.handler(fakeClient, { - endpoint: 'test_read_endpoint', - }); - const result = JSON.parse(content.content[0].text); - - expect(result).toEqual(endpoints[0]?.tool); - }); - - it('should throw error for non-existent endpoint', async () => { - await expect( - toolsMap.get_api_endpoint_schema.handler(fakeClient, { endpoint: 'non_existent_endpoint' }), - ).rejects.toThrow('Endpoint non_existent_endpoint not found'); - }); - - it('should throw error when no endpoint provided', async () => { - await expect(toolsMap.get_api_endpoint_schema.handler(fakeClient, undefined)).rejects.toThrow( - 'No endpoint provided', - ); - }); - }); - - describe('invoke_api_endpoint', () => { - it('should successfully invoke endpoint with valid arguments', async () => { - const mockHandler = endpoints[0]?.handler as jest.Mock; - mockHandler.mockClear(); - - await toolsMap.invoke_api_endpoint.handler(fakeClient, { - endpoint_name: 'test_read_endpoint', - args: { testParam: 'test value' }, - }); - - expect(mockHandler).toHaveBeenCalledWith(fakeClient, { testParam: 'test value' }); - }); - - it('should throw error for non-existent endpoint', async () => { - await expect( - toolsMap.invoke_api_endpoint.handler(fakeClient, { - endpoint_name: 'non_existent_endpoint', - args: { testParam: 'test value' }, - }), - ).rejects.toThrow(/Endpoint non_existent_endpoint not found/); - }); - - it('should throw error when no arguments provided', async () => { - await expect(toolsMap.invoke_api_endpoint.handler(fakeClient, undefined)).rejects.toThrow( - 'No endpoint provided', - ); - }); - - it('should throw error for invalid argument schema', async () => { - await expect( - toolsMap.invoke_api_endpoint.handler(fakeClient, { - endpoint_name: 'test_read_endpoint', - args: { wrongParam: 'test value' }, // Missing required testParam - }), - ).rejects.toThrow(/Invalid arguments for endpoint/); - }); - }); - - function toolOrError(name: string) { - const tool = tools.find((tool) => tool.tool.name === name); - if (!tool) throw new Error(`Tool ${name} not found`); - return tool; - } -}); - -function makeEndpoint( - name: string, - resource: string, - operation: 'read' | 'write', - tags: string[] = [], -): Endpoint { - return { - metadata: { - resource, - operation, - tags, - }, - tool: { - name, - description: `Test endpoint for ${name}`, - inputSchema: { - type: 'object', - properties: { - testParam: { type: 'string' }, - }, - required: ['testParam'], - }, - }, - handler: jest.fn().mockResolvedValue({ success: true }), - }; -} diff --git a/packages/mcp-server/tests/options.test.ts b/packages/mcp-server/tests/options.test.ts index 4d9b60c..532666a 100644 --- a/packages/mcp-server/tests/options.test.ts +++ b/packages/mcp-server/tests/options.test.ts @@ -1,6 +1,4 @@ import { parseCLIOptions, parseQueryOptions } from '../src/options'; -import { Filter } from '../src/tools'; -import { parseEmbeddedJSON } from '../src/compat'; // Mock process.argv const mockArgv = (args: string[]) => { @@ -12,338 +10,35 @@ const mockArgv = (args: string[]) => { }; describe('parseCLIOptions', () => { - it('should parse basic filter options', () => { - const cleanup = mockArgv([ - '--tool=test-tool', - '--resource=test-resource', - '--operation=read', - '--tag=test-tag', - ]); + it('default parsing should be stdio', () => { + const cleanup = mockArgv([]); const result = parseCLIOptions(); - expect(result.filters).toEqual([ - { type: 'tag', op: 'include', value: 'test-tag' }, - { type: 'resource', op: 'include', value: 'test-resource' }, - { type: 'tool', op: 'include', value: 'test-tool' }, - { type: 'operation', op: 'include', value: 'read' }, - ] as Filter[]); - - expect(result.capabilities).toEqual({}); - - expect(result.list).toBe(false); + expect(result.transport).toBe('stdio'); cleanup(); }); - it('should parse exclusion filters', () => { - const cleanup = mockArgv([ - '--no-tool=exclude-tool', - '--no-resource=exclude-resource', - '--no-operation=write', - '--no-tag=exclude-tag', - ]); + it('using http transport with a port', () => { + const cleanup = mockArgv(['--transport=http', '--port=2222']); const result = parseCLIOptions(); - expect(result.filters).toEqual([ - { type: 'tag', op: 'exclude', value: 'exclude-tag' }, - { type: 'resource', op: 'exclude', value: 'exclude-resource' }, - { type: 'tool', op: 'exclude', value: 'exclude-tool' }, - { type: 'operation', op: 'exclude', value: 'write' }, - ] as Filter[]); - - expect(result.capabilities).toEqual({}); - - cleanup(); - }); - - it('should parse client presets', () => { - const cleanup = mockArgv(['--client=openai-agents']); - - const result = parseCLIOptions(); - - expect(result.client).toEqual('openai-agents'); - - cleanup(); - }); - - it('should parse individual capabilities', () => { - const cleanup = mockArgv([ - '--capability=top-level-unions', - '--capability=valid-json', - '--capability=refs', - '--capability=unions', - '--capability=tool-name-length=40', - ]); - - const result = parseCLIOptions(); - - expect(result.capabilities).toEqual({ - topLevelUnions: true, - validJson: true, - refs: true, - unions: true, - toolNameLength: 40, - }); - - cleanup(); - }); - - it('should handle list option', () => { - const cleanup = mockArgv(['--list']); - - const result = parseCLIOptions(); - - expect(result.list).toBe(true); - - cleanup(); - }); - - it('should handle multiple filters of the same type', () => { - const cleanup = mockArgv(['--tool=tool1', '--tool=tool2', '--resource=res1', '--resource=res2']); - - const result = parseCLIOptions(); - - expect(result.filters).toEqual([ - { type: 'resource', op: 'include', value: 'res1' }, - { type: 'resource', op: 'include', value: 'res2' }, - { type: 'tool', op: 'include', value: 'tool1' }, - { type: 'tool', op: 'include', value: 'tool2' }, - ] as Filter[]); - - cleanup(); - }); - - it('should handle comma-separated values in array options', () => { - const cleanup = mockArgv([ - '--tool=tool1,tool2', - '--resource=res1,res2', - '--capability=top-level-unions,valid-json,unions', - ]); - - const result = parseCLIOptions(); - - expect(result.filters).toEqual([ - { type: 'resource', op: 'include', value: 'res1' }, - { type: 'resource', op: 'include', value: 'res2' }, - { type: 'tool', op: 'include', value: 'tool1' }, - { type: 'tool', op: 'include', value: 'tool2' }, - ] as Filter[]); - - expect(result.capabilities).toEqual({ - topLevelUnions: true, - validJson: true, - unions: true, - }); - - cleanup(); - }); - - it('should handle invalid tool-name-length format', () => { - const cleanup = mockArgv(['--capability=tool-name-length=invalid']); - - // Mock console.error to prevent output during test - const originalError = console.error; - console.error = jest.fn(); - - expect(() => parseCLIOptions()).toThrow(); - - console.error = originalError; - cleanup(); - }); - - it('should handle unknown capability', () => { - const cleanup = mockArgv(['--capability=unknown-capability']); - - // Mock console.error to prevent output during test - const originalError = console.error; - console.error = jest.fn(); - - expect(() => parseCLIOptions()).toThrow(); - - console.error = originalError; + expect(result.transport).toBe('http'); + expect(result.port).toBe('2222'); cleanup(); }); }); describe('parseQueryOptions', () => { - const defaultOptions = { - client: undefined, - includeDynamicTools: undefined, - includeCodeTools: undefined, - includeAllTools: undefined, - filters: [], - capabilities: { - topLevelUnions: true, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: undefined, - }, - }; - - it('should parse basic filter options from query string', () => { - const query = 'tool=test-tool&resource=test-resource&operation=read&tag=test-tag'; - const result = parseQueryOptions(defaultOptions, query); - - expect(result.filters).toEqual([ - { type: 'resource', op: 'include', value: 'test-resource' }, - { type: 'operation', op: 'include', value: 'read' }, - { type: 'tag', op: 'include', value: 'test-tag' }, - { type: 'tool', op: 'include', value: 'test-tool' }, - ]); - - expect(result.capabilities).toEqual({ - topLevelUnions: true, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: undefined, - }); - }); - - it('should parse exclusion filters from query string', () => { - const query = 'no_tool=exclude-tool&no_resource=exclude-resource&no_operation=write&no_tag=exclude-tag'; - const result = parseQueryOptions(defaultOptions, query); - - expect(result.filters).toEqual([ - { type: 'resource', op: 'exclude', value: 'exclude-resource' }, - { type: 'operation', op: 'exclude', value: 'write' }, - { type: 'tag', op: 'exclude', value: 'exclude-tag' }, - { type: 'tool', op: 'exclude', value: 'exclude-tool' }, - ]); - }); - - it('should parse client option from query string', () => { - const query = 'client=openai-agents'; - const result = parseQueryOptions(defaultOptions, query); - - expect(result.client).toBe('openai-agents'); - }); - - it('should parse client capabilities from query string', () => { - const query = 'capability=top-level-unions&capability=valid-json&capability=tool-name-length%3D40'; - const result = parseQueryOptions(defaultOptions, query); - - expect(result.capabilities).toEqual({ - topLevelUnions: true, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: 40, - }); - }); - - it('should parse no-capability options from query string', () => { - const query = 'no_capability=top-level-unions&no_capability=refs&no_capability=formats'; - const result = parseQueryOptions(defaultOptions, query); - - expect(result.capabilities).toEqual({ - topLevelUnions: false, - validJson: true, - refs: false, - unions: true, - formats: false, - toolNameLength: undefined, - }); - }); - - it('should parse tools options from query string', () => { - const query = 'tools=dynamic&tools=all'; - const result = parseQueryOptions(defaultOptions, query); - - expect(result.includeDynamicTools).toBe(true); - expect(result.includeAllTools).toBe(true); - }); - - it('should parse no-tools options from query string', () => { - const query = 'tools=dynamic&tools=all&no_tools=dynamic'; - const result = parseQueryOptions(defaultOptions, query); - - expect(result.includeDynamicTools).toBe(false); - expect(result.includeAllTools).toBe(true); - }); - - it('should handle array values in query string', () => { - const query = 'tool[]=tool1&tool[]=tool2&resource[]=res1&resource[]=res2'; - const result = parseQueryOptions(defaultOptions, query); - - expect(result.filters).toEqual([ - { type: 'resource', op: 'include', value: 'res1' }, - { type: 'resource', op: 'include', value: 'res2' }, - { type: 'tool', op: 'include', value: 'tool1' }, - { type: 'tool', op: 'include', value: 'tool2' }, - ]); - }); - - it('should merge with default options', () => { - const defaultWithFilters = { - ...defaultOptions, - filters: [{ type: 'tag' as const, op: 'include' as const, value: 'existing-tag' }], - client: 'cursor' as const, - includeDynamicTools: true, - }; - - const query = 'tool=new-tool&resource=new-resource'; - const result = parseQueryOptions(defaultWithFilters, query); - - expect(result.filters).toEqual([ - { type: 'tag', op: 'include', value: 'existing-tag' }, - { type: 'resource', op: 'include', value: 'new-resource' }, - { type: 'tool', op: 'include', value: 'new-tool' }, - ]); - - expect(result.client).toBe('cursor'); - expect(result.includeDynamicTools).toBe(true); - }); - - it('should override client from default options', () => { - const defaultWithClient = { - ...defaultOptions, - client: 'cursor' as const, - }; + const defaultOptions = {}; - const query = 'client=openai-agents'; - const result = parseQueryOptions(defaultWithClient, query); - - expect(result.client).toBe('openai-agents'); - }); - - it('should merge capabilities with default options', () => { - const defaultWithCapabilities = { - ...defaultOptions, - capabilities: { - topLevelUnions: false, - validJson: false, - refs: true, - unions: true, - formats: true, - toolNameLength: 30, - }, - }; - - const query = 'capability=top-level-unions&no_capability=refs'; - const result = parseQueryOptions(defaultWithCapabilities, query); - - expect(result.capabilities).toEqual({ - topLevelUnions: true, - validJson: false, - refs: false, - unions: true, - formats: true, - toolNameLength: 30, - }); - }); - - it('should handle empty query string', () => { + it('default parsing should be empty', () => { const query = ''; const result = parseQueryOptions(defaultOptions, query); - expect(result).toEqual(defaultOptions); + expect(result).toBe({}); }); it('should handle invalid query string gracefully', () => { @@ -352,189 +47,4 @@ describe('parseQueryOptions', () => { // Should throw due to Zod validation for invalid operation expect(() => parseQueryOptions(defaultOptions, query)).toThrow(); }); - - it('should preserve default undefined values when not specified', () => { - const defaultWithUndefined = { - ...defaultOptions, - client: undefined, - includeDynamicTools: undefined, - includeAllTools: undefined, - }; - - const query = 'tool=test-tool'; - const result = parseQueryOptions(defaultWithUndefined, query); - - expect(result.client).toBeUndefined(); - expect(result.includeDynamicTools).toBeFalsy(); - expect(result.includeAllTools).toBeFalsy(); - }); - - it('should handle complex query with mixed include and exclude filters', () => { - const query = - 'tool=include-tool&no_tool=exclude-tool&resource=include-res&no_resource=exclude-res&operation=read&tag=include-tag&no_tag=exclude-tag'; - const result = parseQueryOptions(defaultOptions, query); - - expect(result.filters).toEqual([ - { type: 'resource', op: 'include', value: 'include-res' }, - { type: 'operation', op: 'include', value: 'read' }, - { type: 'tag', op: 'include', value: 'include-tag' }, - { type: 'tool', op: 'include', value: 'include-tool' }, - { type: 'resource', op: 'exclude', value: 'exclude-res' }, - { type: 'tag', op: 'exclude', value: 'exclude-tag' }, - { type: 'tool', op: 'exclude', value: 'exclude-tool' }, - ]); - }); - - it('code tools are enabled on http servers with default option set', () => { - const query = 'tools=code'; - const result = parseQueryOptions({ ...defaultOptions, includeCodeTools: true }, query); - - expect(result.includeCodeTools).toBe(true); - }); - - it('code tools are prevented on http servers when no default option set', () => { - const query = 'tools=code'; - const result = parseQueryOptions(defaultOptions, query); - - expect(result.includeCodeTools).toBe(undefined); - }); - - it('code tools are prevented on http servers when default option is explicitly false', () => { - const query = 'tools=code'; - const result = parseQueryOptions({ ...defaultOptions, includeCodeTools: false }, query); - - expect(result.includeCodeTools).toBe(false); - }); -}); - -describe('parseEmbeddedJSON', () => { - it('should not change non-string values', () => { - const args = { - numberProp: 42, - booleanProp: true, - objectProp: { nested: 'value' }, - arrayProp: [1, 2, 3], - nullProp: null, - undefinedProp: undefined, - }; - const schema = {}; - - const result = parseEmbeddedJSON(args, schema); - - expect(result).toBe(args); // Should return original object since no changes made - expect(result['numberProp']).toBe(42); - expect(result['booleanProp']).toBe(true); - expect(result['objectProp']).toEqual({ nested: 'value' }); - expect(result['arrayProp']).toEqual([1, 2, 3]); - expect(result['nullProp']).toBe(null); - expect(result['undefinedProp']).toBe(undefined); - }); - - it('should parse valid JSON objects in string properties', () => { - const args = { - jsonObjectString: '{"key": "value", "number": 123}', - regularString: 'not json', - }; - const schema = {}; - - const result = parseEmbeddedJSON(args, schema); - - expect(result).not.toBe(args); // Should return new object since changes were made - expect(result['jsonObjectString']).toEqual({ key: 'value', number: 123 }); - expect(result['regularString']).toBe('not json'); - }); - - it('should leave invalid JSON in string properties unchanged', () => { - const args = { - invalidJson1: '{"key": value}', // Missing quotes around value - invalidJson2: '{key: "value"}', // Missing quotes around key - invalidJson3: '{"key": "value",}', // Trailing comma - invalidJson4: 'just a regular string', - emptyString: '', - }; - const schema = {}; - - const result = parseEmbeddedJSON(args, schema); - - expect(result).toBe(args); // Should return original object since no changes made - expect(result['invalidJson1']).toBe('{"key": value}'); - expect(result['invalidJson2']).toBe('{key: "value"}'); - expect(result['invalidJson3']).toBe('{"key": "value",}'); - expect(result['invalidJson4']).toBe('just a regular string'); - expect(result['emptyString']).toBe(''); - }); - - it('should not parse JSON primitives in string properties', () => { - const args = { - numberString: '123', - floatString: '45.67', - negativeNumberString: '-89', - booleanTrueString: 'true', - booleanFalseString: 'false', - nullString: 'null', - jsonArrayString: '[1, 2, 3, "test"]', - regularString: 'not json', - }; - const schema = {}; - - const result = parseEmbeddedJSON(args, schema); - - expect(result).toBe(args); // Should return original object since no changes made - expect(result['numberString']).toBe('123'); - expect(result['floatString']).toBe('45.67'); - expect(result['negativeNumberString']).toBe('-89'); - expect(result['booleanTrueString']).toBe('true'); - expect(result['booleanFalseString']).toBe('false'); - expect(result['nullString']).toBe('null'); - expect(result['jsonArrayString']).toBe('[1, 2, 3, "test"]'); - expect(result['regularString']).toBe('not json'); - }); - - it('should handle mixed valid objects and other JSON types', () => { - const args = { - validObject: '{"success": true}', - invalidObject: '{"missing": quote}', - validNumber: '42', - validArray: '[1, 2, 3]', - keepAsString: 'hello world', - nonString: 123, - }; - const schema = {}; - - const result = parseEmbeddedJSON(args, schema); - - expect(result).not.toBe(args); // Should return new object since some changes were made - expect(result['validObject']).toEqual({ success: true }); - expect(result['invalidObject']).toBe('{"missing": quote}'); - expect(result['validNumber']).toBe('42'); // Not parsed, remains string - expect(result['validArray']).toBe('[1, 2, 3]'); // Not parsed, remains string - expect(result['keepAsString']).toBe('hello world'); - expect(result['nonString']).toBe(123); - }); - - it('should return original object when no strings are present', () => { - const args = { - number: 42, - boolean: true, - object: { key: 'value' }, - }; - const schema = {}; - - const result = parseEmbeddedJSON(args, schema); - - expect(result).toBe(args); // Should return original object since no changes made - }); - - it('should return original object when all strings are invalid JSON', () => { - const args = { - string1: 'hello', - string2: 'world', - string3: 'not json at all', - }; - const schema = {}; - - const result = parseEmbeddedJSON(args, schema); - - expect(result).toBe(args); // Should return original object since no changes made - }); }); diff --git a/packages/mcp-server/tests/tools.test.ts b/packages/mcp-server/tests/tools.test.ts deleted file mode 100644 index cfff24a..0000000 --- a/packages/mcp-server/tests/tools.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { Endpoint, Filter, Metadata, query } from '../src/tools'; - -describe('Endpoint filtering', () => { - const endpoints: Endpoint[] = [ - endpoint({ - resource: 'user', - operation: 'read', - tags: ['admin'], - toolName: 'retrieve_user', - }), - endpoint({ - resource: 'user.profile', - operation: 'write', - tags: [], - toolName: 'create_user_profile', - }), - endpoint({ - resource: 'user.profile', - operation: 'read', - tags: [], - toolName: 'get_user_profile', - }), - endpoint({ - resource: 'user.roles.permissions', - operation: 'write', - tags: ['admin', 'security'], - toolName: 'update_user_role_permissions', - }), - endpoint({ - resource: 'documents.metadata.tags', - operation: 'write', - tags: ['taxonomy', 'metadata'], - toolName: 'create_document_metadata_tags', - }), - endpoint({ - resource: 'organization.settings', - operation: 'read', - tags: ['admin', 'configuration'], - toolName: 'get_organization_settings', - }), - ]; - - const tests: { name: string; filters: Filter[]; expected: string[] }[] = [ - { - name: 'match none', - filters: [], - expected: [], - }, - - // Resource tests - { - name: 'simple resource', - filters: [{ type: 'resource', op: 'include', value: 'user' }], - expected: ['retrieve_user'], - }, - { - name: 'exclude resource', - filters: [{ type: 'resource', op: 'exclude', value: 'user' }], - expected: [ - 'create_user_profile', - 'get_user_profile', - 'update_user_role_permissions', - 'create_document_metadata_tags', - 'get_organization_settings', - ], - }, - { - name: 'resource and subresources', - filters: [{ type: 'resource', op: 'include', value: 'user*' }], - expected: ['retrieve_user', 'create_user_profile', 'get_user_profile', 'update_user_role_permissions'], - }, - { - name: 'just subresources', - filters: [{ type: 'resource', op: 'include', value: 'user.*' }], - expected: ['create_user_profile', 'get_user_profile', 'update_user_role_permissions'], - }, - { - name: 'specific subresource', - filters: [{ type: 'resource', op: 'include', value: 'user.roles.permissions' }], - expected: ['update_user_role_permissions'], - }, - { - name: 'deep wildcard match', - filters: [{ type: 'resource', op: 'include', value: '*.*.tags' }], - expected: ['create_document_metadata_tags'], - }, - - // Operation tests - { - name: 'read operation', - filters: [{ type: 'operation', op: 'include', value: 'read' }], - expected: ['retrieve_user', 'get_user_profile', 'get_organization_settings'], - }, - { - name: 'write operation', - filters: [{ type: 'operation', op: 'include', value: 'write' }], - expected: ['create_user_profile', 'update_user_role_permissions', 'create_document_metadata_tags'], - }, - { - name: 'resource and operation combined', - filters: [ - { type: 'resource', op: 'include', value: 'user.profile' }, - { type: 'operation', op: 'exclude', value: 'write' }, - ], - expected: ['get_user_profile'], - }, - - // Tag tests - { - name: 'admin tag', - filters: [{ type: 'tag', op: 'include', value: 'admin' }], - expected: ['retrieve_user', 'update_user_role_permissions', 'get_organization_settings'], - }, - { - name: 'taxonomy tag', - filters: [{ type: 'tag', op: 'include', value: 'taxonomy' }], - expected: ['create_document_metadata_tags'], - }, - { - name: 'multiple tags (OR logic)', - filters: [ - { type: 'tag', op: 'include', value: 'admin' }, - { type: 'tag', op: 'include', value: 'security' }, - ], - expected: ['retrieve_user', 'update_user_role_permissions', 'get_organization_settings'], - }, - { - name: 'excluding a tag', - filters: [ - { type: 'tag', op: 'include', value: 'admin' }, - { type: 'tag', op: 'exclude', value: 'security' }, - ], - expected: ['retrieve_user', 'get_organization_settings'], - }, - - // Tool name tests - { - name: 'tool name match', - filters: [{ type: 'tool', op: 'include', value: 'get_organization_settings' }], - expected: ['get_organization_settings'], - }, - { - name: 'two tools match', - filters: [ - { type: 'tool', op: 'include', value: 'get_organization_settings' }, - { type: 'tool', op: 'include', value: 'create_user_profile' }, - ], - expected: ['create_user_profile', 'get_organization_settings'], - }, - { - name: 'excluding tool by name', - filters: [ - { type: 'resource', op: 'include', value: 'user*' }, - { type: 'tool', op: 'exclude', value: 'retrieve_user' }, - ], - expected: ['create_user_profile', 'get_user_profile', 'update_user_role_permissions'], - }, - - // Complex combinations - { - name: 'complex filter: read operations with admin tag', - filters: [ - { type: 'operation', op: 'include', value: 'read' }, - { type: 'tag', op: 'include', value: 'admin' }, - ], - expected: [ - 'retrieve_user', - 'get_user_profile', - 'update_user_role_permissions', - 'get_organization_settings', - ], - }, - { - name: 'complex filter: user resources with no tags', - filters: [ - { type: 'resource', op: 'include', value: 'user.profile' }, - { type: 'tag', op: 'exclude', value: 'admin' }, - ], - expected: ['create_user_profile', 'get_user_profile'], - }, - { - name: 'complex filter: user resources and tags', - filters: [ - { type: 'resource', op: 'include', value: 'user.profile' }, - { type: 'tag', op: 'include', value: 'admin' }, - ], - expected: [ - 'retrieve_user', - 'create_user_profile', - 'get_user_profile', - 'update_user_role_permissions', - 'get_organization_settings', - ], - }, - ]; - - tests.forEach((test) => { - it(`filters by ${test.name}`, () => { - const filtered = query(test.filters, endpoints); - expect(filtered.map((e) => e.tool.name)).toEqual(test.expected); - }); - }); -}); - -function endpoint({ - resource, - operation, - tags, - toolName, -}: { - resource: string; - operation: Metadata['operation']; - tags: string[]; - toolName: string; -}): Endpoint { - return { - metadata: { - resource, - operation, - tags, - }, - tool: { name: toolName, inputSchema: { type: 'object', properties: {} } }, - handler: jest.fn(), - }; -} From f7fb438f6cbcc3110300ebba9e406f7d53804cc3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 07:18:48 +0000 Subject: [PATCH 3/9] chore(internal): codegen related update --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 9239282..2634ff3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2025 hyperspell +Copyright 2026 hyperspell Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: From b84e864d022974598ce18c55c71ba7b56ba3dc70 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 07:28:46 +0000 Subject: [PATCH 4/9] docs: prominently feature MCP server setup in root SDK readmes --- README.md | 9 +++++++++ packages/mcp-server/README.md | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0da3963..29170c7 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,15 @@ The REST API documentation can be found on [docs.hyperspell.com](https://docs.hy It is generated with [Stainless](https://www.stainless.com/). +## MCP Server + +Use the Hyperspell MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. + +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=hyperspell-mcp&config=eyJuYW1lIjoiaHlwZXJzcGVsbC1tY3AiLCJ0cmFuc3BvcnQiOiJzc2UiLCJ1cmwiOiJodHRwczovL2h5cGVyc3BlbGwuc3RsbWNwLmNvbS9zc2UifQ) +[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22hyperspell-mcp%22%2C%22type%22%3A%22sse%22%2C%22url%22%3A%22https%3A%2F%2Fhyperspell.stlmcp.com%2Fsse%22%7D) + +> Note: You may need to set environment variables in your MCP client. + ## Installation ```sh diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 47f1412..3e454fb 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -41,14 +41,14 @@ For clients with a configuration JSON, it might look something like this: If you use Cursor, you can install the MCP server by using the button below. You will need to set your environment variables in Cursor's `mcp.json`, which can be found in Cursor Settings > Tools & MCP > New MCP Server. -[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=hyperspell-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImh5cGVyc3BlbGwtbWNwIl0sImVudiI6eyJIWVBFUlNQRUxMX0FQSV9LRVkiOiJTZXQgeW91ciBIWVBFUlNQRUxMX0FQSV9LRVkgaGVyZS4iLCJIWVBFUlNQRUxMX1VTRVJfSUQiOiJTZXQgeW91ciBIWVBFUlNQRUxMX1VTRVJfSUQgaGVyZS4ifX0) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=hyperspell-mcp&config=eyJuYW1lIjoiaHlwZXJzcGVsbC1tY3AiLCJ0cmFuc3BvcnQiOiJzc2UiLCJ1cmwiOiJodHRwczovL2h5cGVyc3BlbGwuc3RsbWNwLmNvbS9zc2UiLCJlbnYiOnsiSFlQRVJTUEVMTF9BUElfS0VZIjoiU2V0IHlvdXIgSFlQRVJTUEVMTF9BUElfS0VZIGhlcmUuIiwiSFlQRVJTUEVMTF9VU0VSX0lEIjoiU2V0IHlvdXIgSFlQRVJTUEVMTF9VU0VSX0lEIGhlcmUuIn19) ### VS Code If you use MCP, you can install the MCP server by clicking the link below. You will need to set your environment variables in VS Code's `mcp.json`, which can be found via Command Palette > MCP: Open User Configuration. -[Open VS Code](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22hyperspell-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22hyperspell-mcp%22%5D%2C%22env%22%3A%7B%22HYPERSPELL_API_KEY%22%3A%22Set%20your%20HYPERSPELL_API_KEY%20here.%22%2C%22HYPERSPELL_USER_ID%22%3A%22Set%20your%20HYPERSPELL_USER_ID%20here.%22%7D%7D) +[Open VS Code](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22hyperspell-mcp%22%2C%22type%22%3A%22sse%22%2C%22url%22%3A%22https%3A%2F%2Fhyperspell.stlmcp.com%2Fsse%22%2C%22env%22%3A%7B%22HYPERSPELL_API_KEY%22%3A%22Set%20your%20HYPERSPELL_API_KEY%20here.%22%2C%22HYPERSPELL_USER_ID%22%3A%22Set%20your%20HYPERSPELL_USER_ID%20here.%22%7D%7D) ### Claude Code @@ -56,7 +56,7 @@ If you use Claude Code, you can install the MCP server by running the command be environment variables in Claude Code's `.claude.json`, which can be found in your home directory. ``` -claude mcp add --transport stdio hyperspell_api --env HYPERSPELL_API_KEY="Your HYPERSPELL_API_KEY here." HYPERSPELL_USER_ID="Your HYPERSPELL_USER_ID here." -- npx -y hyperspell-mcp +claude mcp add hyperspell_mcp_api --env HYPERSPELL_API_KEY="Your HYPERSPELL_API_KEY here." HYPERSPELL_USER_ID="Your HYPERSPELL_USER_ID here." --transport sse https://hyperspell.stlmcp.com/sse ``` ## Code Mode From 91fb24cdf187a0ec47521c2dc8cbc6e6a8d23264 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 07:29:24 +0000 Subject: [PATCH 5/9] chore(internal): fix dockerfile --- packages/mcp-server/Dockerfile | 115 +++++++++++++++++---------------- 1 file changed, 58 insertions(+), 57 deletions(-) diff --git a/packages/mcp-server/Dockerfile b/packages/mcp-server/Dockerfile index 4cbb501..6b4e9a9 100644 --- a/packages/mcp-server/Dockerfile +++ b/packages/mcp-server/Dockerfile @@ -1,49 +1,50 @@ # Dockerfile for Hyperspell MCP Server - # - # This Dockerfile builds a Docker image for the MCP Server. - # - # To build the image locally: - # docker build -f packages/mcp-server/Dockerfile -t hyperspell-mcp:local . - # - # To run the image: - # docker run -i hyperspell-mcp:local [OPTIONS] - # - # Common options: - # --tool= Include specific tools - # --resource= Include tools for specific resources - # --operation=read|write Filter by operation type - # --client= Set client compatibility (e.g., claude, cursor) - # --transport= Set transport type (stdio or http) - # - # For a full list of options: - # docker run -i hyperspell-mcp:local --help - # - # Note: The MCP server uses stdio transport by default. Docker's -i flag - # enables interactive mode, allowing the container to communicate over stdin/stdout. - - # Build stage - FROM node:20-alpine AS builder - - # Install bash for build script - RUN apk add --no-cache bash openssl - - # Set working directory - WORKDIR /build - - # Copy entire repository - COPY . . - - # Install all dependencies and build everything - RUN yarn install --frozen-lockfile && \ - yarn build - - # Production stage - - # Add non-root user - RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 - - # Set working directory - WORKDIR /app +# +# This Dockerfile builds a Docker image for the MCP Server. +# +# To build the image locally: +# docker build -f packages/mcp-server/Dockerfile -t hyperspell-mcp:local . +# +# To run the image: +# docker run -i hyperspell-mcp:local [OPTIONS] +# +# Common options: +# --tool= Include specific tools +# --resource= Include tools for specific resources +# --operation=read|write Filter by operation type +# --client= Set client compatibility (e.g., claude, cursor) +# --transport= Set transport type (stdio or http) +# +# For a full list of options: +# docker run -i hyperspell-mcp:local --help +# +# Note: The MCP server uses stdio transport by default. Docker's -i flag +# enables interactive mode, allowing the container to communicate over stdin/stdout. + +# Build stage +FROM node:20-alpine AS builder + +# Install bash for build script +RUN apk add --no-cache bash openssl + +# Set working directory +WORKDIR /build + +# Copy entire repository +COPY . . + +# Install all dependencies and build everything +RUN yarn install --frozen-lockfile && \ + yarn build + +# Production stage +FROM node:20-alpine + +# Add non-root user +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 + +# Set working directory +WORKDIR /app # Copy the built mcp-server dist directory COPY --from=builder /build/packages/mcp-server/dist ./ @@ -51,20 +52,20 @@ COPY --from=builder /build/packages/mcp-server/dist ./ # Copy node_modules from mcp-server (includes all production deps) COPY --from=builder /build/packages/mcp-server/node_modules ./node_modules - # Copy the built hyperspell into node_modules - COPY --from=builder /build/dist ./node_modules/hyperspell +# Copy the built hyperspell into node_modules +COPY --from=builder /build/dist ./node_modules/hyperspell - # Change ownership to nodejs user - RUN chown -R nodejs:nodejs /app +# Change ownership to nodejs user +RUN chown -R nodejs:nodejs /app - # Switch to non-root user - USER nodejs +# Switch to non-root user +USER nodejs - # The MCP server uses stdio transport by default - # No exposed ports needed for stdio communication +# The MCP server uses stdio transport by default +# No exposed ports needed for stdio communication - # Set the entrypoint to the MCP server - ENTRYPOINT ["node", "index.js"] +# Set the entrypoint to the MCP server +ENTRYPOINT ["node", "index.js"] - # Allow passing arguments to the MCP server - CMD [] +# Allow passing arguments to the MCP server +CMD [] From b3e2519cd7e4163614969336dbfc52b014b5c3c1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 07:32:09 +0000 Subject: [PATCH 6/9] fix(mcp): correct code tool api output types --- packages/mcp-server/src/code-tool-types.ts | 15 ++++++--------- packages/mcp-server/src/code-tool.ts | 20 +++++++++++++++----- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/mcp-server/src/code-tool-types.ts b/packages/mcp-server/src/code-tool-types.ts index 5d9072e..1581eac 100644 --- a/packages/mcp-server/src/code-tool-types.ts +++ b/packages/mcp-server/src/code-tool-types.ts @@ -3,16 +3,13 @@ import { ClientOptions } from 'hyperspell'; export type WorkerInput = { - opts: ClientOptions; + project_name: string; code: string; + client_opts: ClientOptions; }; -export type WorkerSuccess = { +export type WorkerOutput = { + is_error: boolean; result: unknown | null; - logLines: string[]; - errLines: string[]; -}; -export type WorkerError = { - message: string | undefined; - logLines: string[]; - errLines: string[]; + log_lines: string[]; + err_lines: string[]; }; diff --git a/packages/mcp-server/src/code-tool.ts b/packages/mcp-server/src/code-tool.ts index 2a2e926..1bc90cf 100644 --- a/packages/mcp-server/src/code-tool.ts +++ b/packages/mcp-server/src/code-tool.ts @@ -1,9 +1,9 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { McpTool, Metadata, ToolCallResult, asTextContentResult } from './types'; +import { McpTool, Metadata, ToolCallResult, asErrorResult, asTextContentResult } from './types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; import { readEnv } from './server'; -import { WorkerSuccess } from './code-tool-types'; +import { WorkerInput, WorkerOutput } from './code-tool-types'; /** * A tool that runs code against a copy of the SDK. * @@ -43,9 +43,9 @@ export function codeTool(): McpTool { }, body: JSON.stringify({ project_name: 'hyperspell', - client_opts: { userID: readEnv('HYPERSPELL_USER_ID') }, code, - }), + client_opts: { userID: readEnv('HYPERSPELL_USER_ID') }, + } satisfies WorkerInput), }); if (!res.ok) { @@ -56,7 +56,17 @@ export function codeTool(): McpTool { ); } - return asTextContentResult((await res.json()) as WorkerSuccess); + const { is_error, result, log_lines, err_lines } = (await res.json()) as WorkerOutput; + const hasLogs = log_lines.length > 0 || err_lines.length > 0; + const output = { + result, + ...(log_lines.length > 0 && { log_lines }), + ...(err_lines.length > 0 && { err_lines }), + }; + if (is_error) { + return asErrorResult(typeof result === 'string' && !hasLogs ? result : JSON.stringify(output, null, 2)); + } + return asTextContentResult(output); }; return { metadata, tool, handler }; From fd4370d564345e30444e9de3ce30d1305b146c82 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 07:01:51 +0000 Subject: [PATCH 7/9] chore: break long lines in snippets into multiline --- README.md | 4 ++- tests/api-resources/auth.test.ts | 6 +++- .../integrations/web-crawler.test.ts | 6 +++- tests/api-resources/memories.test.ts | 8 ++++- tests/index.test.ts | 30 +++++++++++++++---- 5 files changed, 45 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 29170c7..6e6fb8e 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,9 @@ const response = await client.memories.add({ text: 'text' }).asResponse(); console.log(response.headers.get('X-My-Header')); console.log(response.statusText); // access the underlying Response object -const { data: memoryStatus, response: raw } = await client.memories.add({ text: 'text' }).withResponse(); +const { data: memoryStatus, response: raw } = await client.memories + .add({ text: 'text' }) + .withResponse(); console.log(raw.headers.get('X-My-Header')); console.log(memoryStatus.resource_id); ``` diff --git a/tests/api-resources/auth.test.ts b/tests/api-resources/auth.test.ts index 90b552c..a69dbf8 100644 --- a/tests/api-resources/auth.test.ts +++ b/tests/api-resources/auth.test.ts @@ -43,6 +43,10 @@ describe('resource auth', () => { }); test('userToken: required and optional params', async () => { - const response = await client.auth.userToken({ user_id: 'user_id', expires_in: '30m', origin: 'origin' }); + const response = await client.auth.userToken({ + user_id: 'user_id', + expires_in: '30m', + origin: 'origin', + }); }); }); diff --git a/tests/api-resources/integrations/web-crawler.test.ts b/tests/api-resources/integrations/web-crawler.test.ts index 3d805a8..fbaf54a 100644 --- a/tests/api-resources/integrations/web-crawler.test.ts +++ b/tests/api-resources/integrations/web-crawler.test.ts @@ -21,6 +21,10 @@ describe('resource webCrawler', () => { }); test('index: required and optional params', async () => { - const response = await client.integrations.webCrawler.index({ url: 'url', limit: 1, max_depth: 0 }); + const response = await client.integrations.webCrawler.index({ + url: 'url', + limit: 1, + max_depth: 0, + }); }); }); diff --git a/tests/api-resources/memories.test.ts b/tests/api-resources/memories.test.ts index a700ca3..ffe2f29 100644 --- a/tests/api-resources/memories.test.ts +++ b/tests/api-resources/memories.test.ts @@ -45,7 +45,13 @@ describe('resource memories', () => { // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error await expect( client.memories.list( - { collection: 'collection', cursor: 'cursor', filter: 'filter', size: 0, source: 'collections' }, + { + collection: 'collection', + cursor: 'cursor', + filter: 'filter', + size: 0, + source: 'collections', + }, { path: '/_stainless_unknown_path' }, ), ).rejects.toThrow(Hyperspell.NotFoundError); diff --git a/tests/index.test.ts b/tests/index.test.ts index a58bdf0..2c9dae1 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -134,7 +134,11 @@ describe('instantiate client', () => { }; process.env['HYPERSPELL_LOG'] = 'debug'; - const client = new Hyperspell({ logger: logger, apiKey: 'My API Key', userID: 'My User ID' }); + const client = new Hyperspell({ + logger: logger, + apiKey: 'My API Key', + userID: 'My User ID', + }); expect(client.logLevel).toBe('debug'); await forceAPIResponseForClient(client); @@ -151,7 +155,11 @@ describe('instantiate client', () => { }; process.env['HYPERSPELL_LOG'] = 'not a log level'; - const client = new Hyperspell({ logger: logger, apiKey: 'My API Key', userID: 'My User ID' }); + const client = new Hyperspell({ + logger: logger, + apiKey: 'My API Key', + userID: 'My User ID', + }); expect(client.logLevel).toBe('warn'); expect(warnMock).toHaveBeenCalledWith( 'process.env[\'HYPERSPELL_LOG\'] was set to "not a log level", expected one of ["off","error","warn","info","debug"]', @@ -383,7 +391,11 @@ describe('instantiate client', () => { }); test('maxRetries option is correctly set', () => { - const client = new Hyperspell({ maxRetries: 4, apiKey: 'My API Key', userID: 'My User ID' }); + const client = new Hyperspell({ + maxRetries: 4, + apiKey: 'My API Key', + userID: 'My User ID', + }); expect(client.maxRetries).toEqual(4); // default @@ -759,7 +771,11 @@ describe('retries', () => { return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); }; - const client = new Hyperspell({ apiKey: 'My API Key', userID: 'My User ID', fetch: testFetch }); + const client = new Hyperspell({ + apiKey: 'My API Key', + userID: 'My User ID', + fetch: testFetch, + }); expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 }); expect(count).toEqual(2); @@ -789,7 +805,11 @@ describe('retries', () => { return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); }; - const client = new Hyperspell({ apiKey: 'My API Key', userID: 'My User ID', fetch: testFetch }); + const client = new Hyperspell({ + apiKey: 'My API Key', + userID: 'My User ID', + fetch: testFetch, + }); expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 }); expect(count).toEqual(2); From 918c00b99d1a5d113162357a84fe30df1cd7f0d4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 07:04:02 +0000 Subject: [PATCH 8/9] fix(mcp): fix options parsing --- packages/mcp-server/src/options.ts | 4 ++-- packages/mcp-server/tests/options.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/mcp-server/src/options.ts b/packages/mcp-server/src/options.ts index 6c8bb8d..c66ad8c 100644 --- a/packages/mcp-server/src/options.ts +++ b/packages/mcp-server/src/options.ts @@ -55,7 +55,7 @@ export function parseCLIOptions(): CLIOptions { const transport = argv.transport as 'stdio' | 'http'; return { - includeDocsTools, + ...(includeDocsTools !== undefined && { includeDocsTools }), transport, port: argv.port, socket: argv.socket, @@ -87,6 +87,6 @@ export function parseQueryOptions(defaultOptions: McpOptions, query: unknown): M : defaultOptions.includeDocsTools; return { - includeDocsTools: docsTools, + ...(docsTools !== undefined && { includeDocsTools: docsTools }), }; } diff --git a/packages/mcp-server/tests/options.test.ts b/packages/mcp-server/tests/options.test.ts index 532666a..7a2d511 100644 --- a/packages/mcp-server/tests/options.test.ts +++ b/packages/mcp-server/tests/options.test.ts @@ -26,7 +26,7 @@ describe('parseCLIOptions', () => { const result = parseCLIOptions(); expect(result.transport).toBe('http'); - expect(result.port).toBe('2222'); + expect(result.port).toBe(2222); cleanup(); }); }); @@ -38,13 +38,13 @@ describe('parseQueryOptions', () => { const query = ''; const result = parseQueryOptions(defaultOptions, query); - expect(result).toBe({}); + expect(result).toEqual({}); }); it('should handle invalid query string gracefully', () => { - const query = 'invalid=value&operation=invalid-operation'; + const query = 'invalid=value&tools=invalid-operation'; - // Should throw due to Zod validation for invalid operation + // Should throw due to Zod validation for invalid tools expect(() => parseQueryOptions(defaultOptions, query)).toThrow(); }); }); From 3f1106bea57ef3a59208081ede801ef937d102c5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 07:04:19 +0000 Subject: [PATCH 9/9] release: 0.30.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ package.json | 2 +- packages/mcp-server/package.json | 2 +- packages/mcp-server/src/server.ts | 2 +- src/version.ts | 2 +- 6 files changed, 33 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 8316a6d..716d004 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.29.0" + ".": "0.30.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index d817ddd..e662b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.30.0 (2026-01-07) + +Full Changelog: [v0.29.0...v0.30.0](https://github.com/hyperspell/node-sdk/compare/v0.29.0...v0.30.0) + +### ⚠ BREAKING CHANGES + +* **mcp:** remove deprecated tool schemes +* **mcp:** **Migration:** To migrate, simply modify the command used to invoke the MCP server. Currently, the only supported tool scheme is code mode. Now, starting the server with just `node /path/to/mcp/server` or `npx package-name` will invoke code tools: changing your command to one of these is likely all you will need to do. + +### Bug Fixes + +* **mcp:** correct code tool api output types ([b3e2519](https://github.com/hyperspell/node-sdk/commit/b3e2519cd7e4163614969336dbfc52b014b5c3c1)) +* **mcp:** fix options parsing ([918c00b](https://github.com/hyperspell/node-sdk/commit/918c00b99d1a5d113162357a84fe30df1cd7f0d4)) +* **mcp:** pass base url to code tool ([5cb1499](https://github.com/hyperspell/node-sdk/commit/5cb1499f5651f1fd65ff80c6ed9f231c160b0a62)) + + +### Chores + +* break long lines in snippets into multiline ([fd4370d](https://github.com/hyperspell/node-sdk/commit/fd4370d564345e30444e9de3ce30d1305b146c82)) +* **internal:** codegen related update ([f7fb438](https://github.com/hyperspell/node-sdk/commit/f7fb438f6cbcc3110300ebba9e406f7d53804cc3)) +* **internal:** fix dockerfile ([91fb24c](https://github.com/hyperspell/node-sdk/commit/91fb24cdf187a0ec47521c2dc8cbc6e6a8d23264)) +* **mcp:** remove deprecated tool schemes ([5aa93e0](https://github.com/hyperspell/node-sdk/commit/5aa93e0c5b58a2023376fe6e82a91982c122fd91)) + + +### Documentation + +* prominently feature MCP server setup in root SDK readmes ([b84e864](https://github.com/hyperspell/node-sdk/commit/b84e864d022974598ce18c55c71ba7b56ba3dc70)) + ## 0.29.0 (2025-12-16) Full Changelog: [v0.27.0...v0.29.0](https://github.com/hyperspell/node-sdk/compare/v0.27.0...v0.29.0) diff --git a/package.json b/package.json index e36c575..75244aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hyperspell", - "version": "0.29.0", + "version": "0.30.0", "description": "The official TypeScript library for the Hyperspell API", "author": "Hyperspell ", "types": "dist/index.d.ts", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 4812074..5d2f91f 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "hyperspell-mcp", - "version": "0.29.0", + "version": "0.30.0", "description": "The official MCP Server for the Hyperspell API", "author": "Hyperspell ", "types": "dist/index.d.ts", diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index a14e8d5..2965bd0 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -21,7 +21,7 @@ export const newMcpServer = () => new McpServer( { name: 'hyperspell_api', - version: '0.29.0', + version: '0.30.0', }, { capabilities: { tools: {}, logging: {} } }, ); diff --git a/src/version.ts b/src/version.ts index bef2b64..91a9bb6 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.29.0'; // x-release-please-version +export const VERSION = '0.30.0'; // x-release-please-version