Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,30 @@ This is the StackOne AI Node SDK - a TypeScript library that transforms OpenAPI
- **Lazy Loading**: Tools are created on-demand to minimize memory usage
- **Extensibility**: Hooks for parameter transformation and pre-execution logic

### TypeScript Exhaustiveness Checks

When branching on string unions, prefer the `satisfies never` pattern to guarantee compile-time exhaustiveness without introducing temporary variables. Example from `RequestBuilder.buildFetchOptions`:

```ts
switch (bodyType) {
case 'json':
// ...
break;
case 'form':
// ...
break;
case 'multipart-form':
// ...
break;
default: {
bodyType satisfies never; // raises a type error if a new variant is added
throw new Error(`Unsupported HTTP body type: ${String(bodyType)}`);
}
}
```

Use this approach to keep the union definition (`type HttpBodyType = 'json' | 'multipart-form' | 'form'`) and the switch statement in sync. Adding a new union member will cause TypeScript to report the missing case at compile time.

### Testing Strategy

Tests use Bun's built-in test runner with a Jest-compatible API. Key patterns:
Expand All @@ -73,4 +97,4 @@ When modifying the codebase:
- Run tests frequently during development
- Use `bun run rebuild` after updating OpenAPI specs
- Ensure all generated files are committed (they're not gitignored)
- Follow the existing patterns for error handling and logging
- Follow the existing patterns for error handling and logging
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,27 @@ const currentAccountId = tools.getAccountId(); // Get the current account ID

[View full example](examples/account-id-usage.ts)

### Loading the Latest Tool Catalog

Call `fetchTools()` when you want the SDK to pull the current tool definitions directly from StackOne without maintaining local specs:

```typescript
const toolset = new StackOneToolSet({
baseUrl: 'https://api.stackone.com',
});

const tools = await toolset.fetchTools();
const employeeTool = tools.getTool('hris_list_employees');

const result = await employeeTool?.execute({
query: { limit: 5 },
});
```

`fetchTools()` reuses the credentials you already configured (for example via `STACKONE_API_KEY`) and binds the returned tool objects to StackOne's actions client.

[View full example](examples/fetch-tools.ts)

### File Upload

The `StackOneToolSet` comes with built-in transformations for file uploads:
Expand Down
175 changes: 175 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ Shows how to use custom base URLs for development or self-hosted instances.
- **API Calls**: No (dry run)
- **Key Features**: Custom API endpoints, development setup

#### [`fetch-tools.ts`](./fetch-tools.ts) - Live Catalog Loading

Illustrates how to pull the latest tool catalog from StackOne and execute a tool with the fetched definitions.

- **Account ID**: HRIS
- **API Calls**: Yes (requires valid credentials)
- **Key Features**: Catalog refresh, zero local specs, production-style execution

### Advanced Features

#### [`experimental-document-handling.ts`](./experimental-document-handling.ts) - Document Processing
Expand Down
38 changes: 38 additions & 0 deletions examples/fetch-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Example: fetch the latest StackOne tool catalog and execute a tool.
*
* Set `STACKONE_API_KEY` (and optionally `STACKONE_BASE_URL`) before running.
* By default the script exits early in test environments where a real key is
* not available.
*/

import process from 'node:process';
import { StackOneToolSet } from '../src';

const apiKey = process.env.STACKONE_API_KEY;
const isPlaceholderKey = !apiKey || apiKey === 'test-stackone-key';
const shouldSkip = process.env.SKIP_FETCH_TOOLS_EXAMPLE !== '0' && isPlaceholderKey;

if (shouldSkip) {
console.log(
'Skipping fetch-tools example. Provide STACKONE_API_KEY and set SKIP_FETCH_TOOLS_EXAMPLE=0 to run.'
);
process.exit(0);
}

const toolset = new StackOneToolSet({
baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com',
});

const tools = await toolset.fetchTools();
console.log(`Loaded ${tools.length} tools`);

const tool = tools.getTool('hris_list_employees');
if (!tool) {
throw new Error('Tool hris_list_employees not found in the catalog');
}

const result = await tool.execute({
query: { limit: 5 },
});
console.log('Sample execution result:', JSON.stringify(result, null, 2));
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,22 @@
"format": "biome format --write ."
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.19.1",
"@orama/orama": "^3.1.11",
"@stackone/stackone-client-ts": "^4.28.0",
"json-schema": "^0.4.0"
},
"devDependencies": {
"@ai-sdk/openai": "^1.1.14",
"@biomejs/biome": "^1.5.3",
"@hono/mcp": "^0.1.4",
"@types/bun": "^1.2.4",
"@types/json-schema": "^7.0.15",
"@types/node": "^22.13.5",
"@typescript/native-preview": "^7.0.0-dev.20250623.1",
"ai": "^4.1.46",
"fs-fixture": "^2.8.1",
"hono": "^4.9.10",
"lint-staged": "^15.2.0",
"mkdocs": "^0.0.1",
"msw": "^2.10.4",
Expand All @@ -56,7 +60,8 @@
"publint": "^0.3.12",
"tsdown": "^0.15.6",
"type-fest": "^4.41.0",
"unplugin-unused": "^0.5.1"
"unplugin-unused": "^0.5.1",
"zod": "^3.23.8"
},
"peerDependencies": {
"ai": "4.x",
Expand Down
55 changes: 55 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { version } from '../package.json';

interface MCPClientOptions {
baseUrl: string;
headers?: Record<string, string>;
}

interface MCPClient {
/** underlying MCP client */
client: Client;

/** underlying transport */
transport: StreamableHTTPClientTransport;

/** cleanup client and transport */
[Symbol.asyncDispose](): Promise<void>;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

close mcp client with await using

}

/**
* Create a Model Context Protocol (MCP) client.
*
* @example
* ```ts
* import { createMCPClient } from '@stackone/ai';
*
* await using clients = await createMCPClient({
* baseUrl: 'https://api.modelcontextprotocol.org',
* headers: {
* 'Authorization': 'Bearer YOUR_API_KEY',
* },
* });
* ```
*/
export async function createMCPClient({ baseUrl, headers }: MCPClientOptions): Promise<MCPClient> {
const transport = new StreamableHTTPClientTransport(new URL(baseUrl), {
requestInit: {
headers,
},
});

const client = new Client({
name: 'StackOne AI SDK',
version,
});

return {
client,
transport,
async [Symbol.asyncDispose]() {
await Promise.all([client.close(), transport.close()]);
},
};
}
15 changes: 10 additions & 5 deletions src/modules/requestBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type ExecuteConfig,
type ExecuteOptions,
type HttpBodyType,
type HttpExecuteConfig,
type JsonDict,
ParameterLocation,
} from '../types';
Expand All @@ -19,16 +20,16 @@ class ParameterSerializationError extends Error {
}

/**
* Builds and executes HTTP requests
* Builds and executes HTTP requests for tools declared with kind:'http'.
*/
export class RequestBuilder {
private method: string;
private url: string;
private bodyType: 'json' | 'multipart-form' | 'form';
private params: ExecuteConfig['params'];
private bodyType: HttpBodyType;
private params: HttpExecuteConfig['params'];
private headers: Record<string, string>;

constructor(config: ExecuteConfig, headers: Record<string, string> = {}) {
constructor(config: HttpExecuteConfig, headers: Record<string, string> = {}) {
this.method = config.method;
this.url = config.url;
this.bodyType = config.bodyType;
Expand Down Expand Up @@ -144,6 +145,10 @@ export class RequestBuilder {
// Don't set Content-Type for FormData, it will be set automatically with the boundary
break;
}
default: {
this.bodyType satisfies never;
throw new Error(`Unsupported HTTP body type: ${String(this.bodyType)}`);
}
}
}

Expand Down
37 changes: 19 additions & 18 deletions src/modules/tests/requestBuilder.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { http, HttpResponse } from 'msw';
import { server } from '../../../mocks/node.ts';
import { ParameterLocation } from '../../types';
import { type HttpExecuteConfig, ParameterLocation } from '../../types';
import { StackOneAPIError } from '../../utils/errors';
import { RequestBuilder } from '../requestBuilder';

Expand All @@ -16,27 +16,28 @@ describe('RequestBuilder', () => {
return recordedRequests;
};
const mockConfig = {
kind: 'http',
method: 'GET',
url: 'https://api.example.com/test/{pathParam}',
bodyType: 'json' as const,
bodyType: 'json',
params: [
{ name: 'pathParam', location: ParameterLocation.PATH, type: 'string' as const },
{ name: 'queryParam', location: ParameterLocation.QUERY, type: 'string' as const },
{ name: 'headerParam', location: ParameterLocation.HEADER, type: 'string' as const },
{ name: 'bodyParam', location: ParameterLocation.BODY, type: 'string' as const },
{ name: 'defaultParam', location: ParameterLocation.BODY, type: 'string' as const },
{ name: 'filter', location: ParameterLocation.QUERY, type: 'object' as const },
{ name: 'proxy', location: ParameterLocation.QUERY, type: 'object' as const },
{ name: 'regularObject', location: ParameterLocation.QUERY, type: 'object' as const },
{ name: 'simple', location: ParameterLocation.QUERY, type: 'string' as const },
{ name: 'simpleString', location: ParameterLocation.QUERY, type: 'string' as const },
{ name: 'simpleNumber', location: ParameterLocation.QUERY, type: 'number' as const },
{ name: 'simpleBoolean', location: ParameterLocation.QUERY, type: 'boolean' as const },
{ name: 'complexObject', location: ParameterLocation.QUERY, type: 'object' as const },
{ name: 'deepFilter', location: ParameterLocation.QUERY, type: 'object' as const },
{ name: 'emptyFilter', location: ParameterLocation.QUERY, type: 'object' as const },
{ name: 'pathParam', location: ParameterLocation.PATH, type: 'string' },
{ name: 'queryParam', location: ParameterLocation.QUERY, type: 'string' },
{ name: 'headerParam', location: ParameterLocation.HEADER, type: 'string' },
{ name: 'bodyParam', location: ParameterLocation.BODY, type: 'string' },
{ name: 'defaultParam', location: ParameterLocation.BODY, type: 'string' },
{ name: 'filter', location: ParameterLocation.QUERY, type: 'object' },
{ name: 'proxy', location: ParameterLocation.QUERY, type: 'object' },
{ name: 'regularObject', location: ParameterLocation.QUERY, type: 'object' },
{ name: 'simple', location: ParameterLocation.QUERY, type: 'string' },
{ name: 'simpleString', location: ParameterLocation.QUERY, type: 'string' },
{ name: 'simpleNumber', location: ParameterLocation.QUERY, type: 'number' },
{ name: 'simpleBoolean', location: ParameterLocation.QUERY, type: 'boolean' },
{ name: 'complexObject', location: ParameterLocation.QUERY, type: 'object' },
{ name: 'deepFilter', location: ParameterLocation.QUERY, type: 'object' },
{ name: 'emptyFilter', location: ParameterLocation.QUERY, type: 'object' },
],
};
} satisfies HttpExecuteConfig;

beforeEach(() => {
builder = new RequestBuilder(mockConfig, { 'Initial-Header': 'test' });
Expand Down
50 changes: 35 additions & 15 deletions src/openapi/parser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { JSONSchema7 as JsonSchema } from 'json-schema';
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
import { ParameterLocation, type ToolDefinition } from '../types';
import { type HttpExecuteConfig, ParameterLocation, type ToolDefinition } from '../types';
// Define a type for OpenAPI document
type OpenAPIDocument = OpenAPIV3.Document | OpenAPIV3_1.Document;

Expand Down Expand Up @@ -67,6 +67,23 @@ export class OpenAPIParser {
return servers.length > 0 ? servers[0].url : 'https://api.stackone.com';
}

private normalizeBodyType(bodyType: string | null): HttpExecuteConfig['bodyType'] {
// Map OpenAPI content types into the narrower set supported by ExecuteConfig.
if (!bodyType) {
return 'json';
}

if (bodyType === 'form-data' || bodyType === 'multipart-form') {
return 'multipart-form';
}

if (bodyType === 'form' || bodyType === 'application/x-www-form-urlencoded') {
return 'form';
}

return 'json';
}

/**
* Create a parser from a JSON string
* @param specString OpenAPI specification as a JSON string
Expand Down Expand Up @@ -552,27 +569,30 @@ export class OpenAPIParser {
);

// Create tool definition with deep copies to prevent shared state
const executeConfig = {
kind: 'http',
method: method.toUpperCase(),
url: `${this._baseUrl}${path}`,
bodyType: this.normalizeBodyType(bodyType),
params: Object.entries(parameterLocations)
.filter(([name]) => !this.isRemovedParam(name))
.map(([name, location]) => {
return {
name,
location,
type: (filteredProperties[name]?.type as JsonSchema['type']) || 'string',
};
}),
} satisfies HttpExecuteConfig;

tools[name] = {
description: operation.summary || '',
parameters: {
type: 'object',
properties: filteredProperties,
required: filteredRequired,
},
execute: {
method: method.toUpperCase(),
url: `${this._baseUrl}${path}`,
bodyType: (bodyType as 'json' | 'multipart-form') || 'json',
params: Object.entries(parameterLocations)
.filter(([name]) => !this.isRemovedParam(name))
.map(([name, location]) => {
return {
name,
location,
type: (filteredProperties[name]?.type as JsonSchema['type']) || 'string',
};
}),
},
execute: executeConfig,
};
} catch (operationError) {
console.error(`Error processing operation ${name}: ${operationError}`);
Expand Down
Loading
Loading