diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81859d0..3058099 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,13 +17,14 @@ jobs: timeout-minutes: 10 name: lint runs-on: ${{ github.repository == 'stainless-sdks/scan-documents-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 - name: Set up Node uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Bootstrap run: ./scripts/bootstrap @@ -35,6 +36,7 @@ jobs: timeout-minutes: 5 name: build runs-on: ${{ github.repository == 'stainless-sdks/scan-documents-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork permissions: contents: read id-token: write @@ -44,7 +46,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Bootstrap run: ./scripts/bootstrap @@ -66,10 +68,20 @@ jobs: AUTH: ${{ steps.github-oidc.outputs.github_token }} SHA: ${{ github.sha }} run: ./scripts/utils/upload-artifact.sh + + - name: Upload MCP Server tarball + if: github.repository == 'stainless-sdks/scan-documents-typescript' + env: + URL: https://pkg.stainless.com/s?subpackage=mcp-server + AUTH: ${{ steps.github-oidc.outputs.github_token }} + SHA: ${{ github.sha }} + BUILD_PATH: packages/mcp-server/dist + run: ./scripts/utils/upload-artifact.sh test: timeout-minutes: 10 name: test runs-on: ${{ github.repository == 'stainless-sdks/scan-documents-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 12342be..5375ff4 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-alpha.5" + ".": "0.1.0-alpha.6" } diff --git a/.stats.yml b/.stats.yml index 5b2cf0d..0d3cbbe 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 17 +configured_endpoints: 18 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/scan-documents%2Fscan-documents-42f9aa9c3b2769584f60fa3742f3a004bdf2e3b5ba30a44535b0191654b1a51e.yml openapi_spec_hash: a0bab3e1411b24d8a662df98d227049a -config_hash: 4f1180f734cbc7323ff2ed85a9cd510d +config_hash: c1dd12e2ddf127e74f1b4981eef49e2b diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f66d4..a8d34f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,67 @@ # Changelog +## 0.1.0-alpha.6 (2025-08-24) + +Full Changelog: [v0.1.0-alpha.5...v0.1.0-alpha.6](https://github.com/Scan-Documents/node-sdk/compare/v0.1.0-alpha.5...v0.1.0-alpha.6) + +### Features + +* **api:** update via SDK Studio ([29066c7](https://github.com/Scan-Documents/node-sdk/commit/29066c78a803d4eb260385bdf2a3e7748390b91d)) +* **mcp:** add code execution tool ([c36e7ab](https://github.com/Scan-Documents/node-sdk/commit/c36e7ab1ffe8f98f94603aa729b080bd16e034c4)) +* **mcp:** add logging when environment variable is set ([57fc795](https://github.com/Scan-Documents/node-sdk/commit/57fc7959d83c30d25152a60aeb8895df38ab15b8)) +* **mcp:** add option to infer mcp client ([149e514](https://github.com/Scan-Documents/node-sdk/commit/149e5145317a38b8acb37ec99948fc3aab8a2a2b)) +* **mcp:** add unix socket option for remote MCP ([72982bc](https://github.com/Scan-Documents/node-sdk/commit/72982bc112a6a5725956354b361944069cf92faa)) +* **mcp:** fallback for void-typed methods ([965b606](https://github.com/Scan-Documents/node-sdk/commit/965b6064ff025b992e21c9fd54edbe6963d27b6b)) +* **mcp:** parse query string as mcp client options in mcp server ([a72cd42](https://github.com/Scan-Documents/node-sdk/commit/a72cd4273cb2efc360656de84e17c94d2ba21c25)) +* **mcp:** remote server with passthru auth ([05f1022](https://github.com/Scan-Documents/node-sdk/commit/05f1022cf2d8897c5ca52bdac865fea951dabc39)) +* **mcp:** support filtering tool results by a jq expression ([436421e](https://github.com/Scan-Documents/node-sdk/commit/436421e66b382295d19149478a5cabb21bce12a6)) + + +### Bug Fixes + +* **ci:** release-doctor — report correct token name ([2430747](https://github.com/Scan-Documents/node-sdk/commit/2430747fe786df1a9e237b884c59082cfc21a2bf)) +* **client:** get fetchOptions type more reliably ([0a17458](https://github.com/Scan-Documents/node-sdk/commit/0a17458ddfe37d5b3e5c10f7e257fad3e2d08129)) +* **mcp:** avoid sending `jq_filter` to base API ([11b40cc](https://github.com/Scan-Documents/node-sdk/commit/11b40cca569792c127c204a6ca9c39e688b182be)) +* **mcp:** fix tool description of jq_filter ([33effa6](https://github.com/Scan-Documents/node-sdk/commit/33effa6ccb54e2da65a9aaad00bcbb7070e2bdd0)) +* **mcp:** generate additionalProperties=true for map schemas to avoid validation issues ([488ee1c](https://github.com/Scan-Documents/node-sdk/commit/488ee1c68366d5253d75565838c2c3447af525b1)) +* **mcp:** include required section for top-level properties and support naming transformations ([f7827a6](https://github.com/Scan-Documents/node-sdk/commit/f7827a6390ec0922cdc039344f47774cf588da14)) +* **mcp:** relax input type for asTextContextResult ([7f50ae3](https://github.com/Scan-Documents/node-sdk/commit/7f50ae35b174c4e7863bfc71c71ce70f71893ef7)) +* **mcp:** reverse validJson capability option and limit scope ([41bd333](https://github.com/Scan-Documents/node-sdk/commit/41bd333e563e4b23100f1f239928aa8c60fc223c)) +* **mcp:** support jq filtering on cloudflare workers ([d2bee12](https://github.com/Scan-Documents/node-sdk/commit/d2bee126b3794e12e7a66fd1df7600a89c7adf7a)) + + +### Chores + +* add docs to RequestOptions type ([26a2df1](https://github.com/Scan-Documents/node-sdk/commit/26a2df16f3133c6f3ebfa06a906ccbdb667b763c)) +* add package to package.json ([52c2226](https://github.com/Scan-Documents/node-sdk/commit/52c22269924c2992a427ce0136bf1f9effd4da8b)) +* **ci:** only run for pushes and fork pull requests ([fedf819](https://github.com/Scan-Documents/node-sdk/commit/fedf819df77819d08582e26bf2d768a0971684d0)) +* **client:** improve path param validation ([00bb123](https://github.com/Scan-Documents/node-sdk/commit/00bb1237344ad7b4d3cafb5ee3f8015033f581ad)) +* **client:** qualify global Blob ([b110292](https://github.com/Scan-Documents/node-sdk/commit/b110292d90aa445e9d73dd7cfa77d2bd57e46aa9)) +* **deps:** update dependency @types/node to v20.17.58 ([b435ab1](https://github.com/Scan-Documents/node-sdk/commit/b435ab1ef8e26e20a35842579846c87cd20ff4be)) +* **internal:** codegen related update ([5560f78](https://github.com/Scan-Documents/node-sdk/commit/5560f78a373d434a2b1a86f57fd14da284581bc0)) +* **internal:** codegen related update ([3554f3c](https://github.com/Scan-Documents/node-sdk/commit/3554f3cab5ce7969889c889bb833e8aa45740159)) +* **internal:** codegen related update ([4d9ab35](https://github.com/Scan-Documents/node-sdk/commit/4d9ab3563cfe93b7a61097a484e9f37f7604c5b7)) +* **internal:** codegen related update ([b47458d](https://github.com/Scan-Documents/node-sdk/commit/b47458d7c6269e4acbfc6fb4b469597581633e49)) +* **internal:** formatting change ([5a0343c](https://github.com/Scan-Documents/node-sdk/commit/5a0343c3751c96f3637abfd0ea84df271abf70a2)) +* **internal:** make mcp-server publishing public by defaut ([cb532ff](https://github.com/Scan-Documents/node-sdk/commit/cb532ff281e968d46b3d5dd5710d36ac9bdd530e)) +* **internal:** move publish config ([1947212](https://github.com/Scan-Documents/node-sdk/commit/1947212b285dd090527c98fb607308003036e1e3)) +* **internal:** refactor array check ([39a25bf](https://github.com/Scan-Documents/node-sdk/commit/39a25bf20d4cafdca951e6aa0fa0bf74a00b378f)) +* **internal:** remove redundant imports config ([cfe049c](https://github.com/Scan-Documents/node-sdk/commit/cfe049c407230ed71a4216f893cedb1c85ba856e)) +* **internal:** update comment in script ([3be780c](https://github.com/Scan-Documents/node-sdk/commit/3be780cb1d9bc7136a79a2f130617a42bd38b081)) +* make some internal functions async ([064caed](https://github.com/Scan-Documents/node-sdk/commit/064caed6799b56c06d441e02319f31ca79f0549f)) +* **mcp:** add cors to oauth metadata route ([e47261c](https://github.com/Scan-Documents/node-sdk/commit/e47261c644a962bc76c7ad5afb92ded38034098d)) +* **mcp:** document remote server in README.md ([64f8b3a](https://github.com/Scan-Documents/node-sdk/commit/64f8b3a076b881b43878aa2e78c2942a6b53d98d)) +* **mcp:** formatting ([32923a2](https://github.com/Scan-Documents/node-sdk/commit/32923a200bbe8c6ba2658428be044bd14e5f09cb)) +* **mcp:** minor cleanup of types and package.json ([938a40b](https://github.com/Scan-Documents/node-sdk/commit/938a40bc3dc1b8f4cacdea781c23dea0680ac1eb)) +* **mcp:** refactor streamable http transport ([8a955ea](https://github.com/Scan-Documents/node-sdk/commit/8a955eaa7bb01a0a85817567cd0000a15cdca487)) +* **mcp:** rework imports in tools ([df24a2f](https://github.com/Scan-Documents/node-sdk/commit/df24a2f821d16e2ef006bf6715be5d87b1179a83)) +* **mcp:** update package.json ([261f3f5](https://github.com/Scan-Documents/node-sdk/commit/261f3f5cef6e12b4d435264bc11ce5cc52f6ec6b)) +* **mcp:** update README ([b821690](https://github.com/Scan-Documents/node-sdk/commit/b821690f1b0a2494dcb6b2ab0643a212b4a040dc)) +* **mcp:** update types ([079839c](https://github.com/Scan-Documents/node-sdk/commit/079839c4089e8cf828d49a36339dc8764e1b6e71)) +* **ts:** reorder package.json imports ([596fc4f](https://github.com/Scan-Documents/node-sdk/commit/596fc4f6d1323ceeecf8eef62cc165d5430f9069)) +* update @stainless-api/prism-cli to v5.15.0 ([b609b33](https://github.com/Scan-Documents/node-sdk/commit/b609b338d3f8db485a3ede5f4fe920db21c79149)) +* update CI script ([2ace297](https://github.com/Scan-Documents/node-sdk/commit/2ace29730fa8c3b74531c0945e955b1339fafc6c)) + ## 0.1.0-alpha.5 (2025-06-24) Full Changelog: [v0.1.0-alpha.4...v0.1.0-alpha.5](https://github.com/Scan-Documents/node-sdk/compare/v0.1.0-alpha.4...v0.1.0-alpha.5) diff --git a/README.md b/README.md index 7c31c42..4802719 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,6 @@ await client.files.upload({ file: await toFile(Buffer.from('my bytes'), 'file'), await client.files.upload({ file: await toFile(new Uint8Array([0, 1, 2]), 'file'), name: 'File Name' }); ``` - ## Task operations Operations can be found under the `imageOperations` and `pdfOperations` resources. diff --git a/api.md b/api.md index 1984514..d1640a6 100644 --- a/api.md +++ b/api.md @@ -54,6 +54,7 @@ Types: - ExtractTextRequest - ExtractTextResponse - ImageFromTaskResponse +- ScanResponse - WarpRequest - WarpResponse @@ -63,6 +64,7 @@ Methods: - client.imageOperations.convert({ ...params }) -> ConvertResponse - client.imageOperations.detectDocuments({ ...params }) -> DetectDocumentsResponse - client.imageOperations.extractText({ ...params }) -> ExtractTextResponse +- client.imageOperations.scan({ ...params }) -> ScanResponse - client.imageOperations.warp({ ...params }) -> WarpResponse # PdfOperations diff --git a/bin/check-release-environment b/bin/check-release-environment index d3cfc3d..e4b6d58 100644 --- a/bin/check-release-environment +++ b/bin/check-release-environment @@ -3,7 +3,7 @@ errors=() if [ -z "${NPM_TOKEN}" ]; then - errors+=("The SCAN_DOCUMENTS_NPM_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets") + errors+=("The NPM_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets") fi lenErrors=${#errors[@]} diff --git a/bin/publish-npm b/bin/publish-npm index fa2243d..45e8aa8 100644 --- a/bin/publish-npm +++ b/bin/publish-npm @@ -58,4 +58,4 @@ else fi # Publish with the appropriate tag -yarn publish --access public --tag "$TAG" +yarn publish --tag "$TAG" diff --git a/package.json b/package.json index f3fe912..d6fc9b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scan-documents", - "version": "0.1.0-alpha.5", + "version": "0.1.0-alpha.6", "description": "The official TypeScript library for the Scan Documents API", "author": "Scan Documents ", "types": "dist/index.d.ts", @@ -13,6 +13,9 @@ "**/*" ], "private": false, + "publishConfig": { + "access": "public" + }, "scripts": { "test": "./scripts/test", "build": "./scripts/build", @@ -30,7 +33,6 @@ "@swc/jest": "^0.2.29", "@types/jest": "^29.4.0", "@types/node": "^20.17.6", - "typescript-eslint": "8.31.1", "@typescript-eslint/eslint-plugin": "8.31.1", "@typescript-eslint/parser": "8.31.1", "eslint": "^9.20.1", @@ -42,13 +44,11 @@ "publint": "^0.2.12", "ts-jest": "^29.1.0", "ts-node": "^10.5.0", - "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.8/tsc-multi.tgz", + "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz", "tsconfig-paths": "^4.0.0", - "typescript": "5.8.3" - }, - "imports": { - "scan-documents": ".", - "scan-documents/*": "./src/*" + "tslib": "^2.8.1", + "typescript": "5.8.3", + "typescript-eslint": "8.31.1" }, "exports": { ".": { diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index f36e13c..b0aab00 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -126,6 +126,43 @@ over time, you can manually enable or disable certain capabilities: --resource=cards,accounts --operation=read --tag=kyc --no-tool=create_cards ``` +## Running remotely + +Launching the client with `--transport=http` launches the server as a remote server using Streamable HTTP transport. The `--port` setting can choose the port it will run on, and the `--socket` setting allows it to run on a Unix socket. + +Authorization can be provided via the following headers: +| Header | Equivalent client option | Security scheme | +| ----------- | ------------------------ | --------------- | +| `x-api-key` | `apiKey` | ApiKeyAuth | + +A configuration JSON for this server might look like this, assuming the server is hosted at `http://localhost:3000`: + +```json +{ + "mcpServers": { + "scan_documents_api": { + "url": "http://localhost:3000", + "headers": { + "x-api-key": "My API Key" + } + } + } +} +``` + +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 @@ -188,6 +225,8 @@ The following tools are available in this MCP server. - `convert_image_operations` (`write`): Creates a task to convert an image file to a different format. - `detect_documents_image_operations` (`write`): Creates a task to detect document boundaries within an image. - `extract_text_image_operations` (`write`): Creates a task to extract text from a specified image file. +- `scan_image_operations` (`write`): Creates a task to scan an image file. + This is an equivalent operation for `detect-documents` and `warp` combined, additionally it can apply effects to the scanned image. - `warp_image_operations` (`write`): Creates a task to apply perspective correction (warp) to an image based on detected document boundaries. ### Resource `pdf_operations`: diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 4939d5b..24b51c2 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "scan-documents-mcp", - "version": "0.1.0-alpha.5", + "version": "0.1.0-alpha.6", "description": "The official MCP Server for the Scan Documents API", "author": "Scan Documents ", "types": "dist/index.d.ts", @@ -15,6 +15,9 @@ "license": "Apache-2.0", "packageManager": "yarn@1.22.22", "private": false, + "publishConfig": { + "access": "public" + }, "scripts": { "test": "jest", "build": "bash ./build", @@ -28,17 +31,27 @@ }, "dependencies": { "scan-documents": "file:../../dist/", + "@cloudflare/cabidela": "^0.2.4", "@modelcontextprotocol/sdk": "^1.11.5", + "@valtown/deno-http-worker": "^0.0.21", + "cors": "^2.8.5", + "express": "^5.1.0", + "jq-web": "https://github.com/stainless-api/jq-web/releases/download/v0.8.6/jq-web.tar.gz", + "qs": "^6.14.0", "yargs": "^17.7.2", - "@cloudflare/cabidela": "^0.2.4", "zod": "^3.25.20", - "zod-to-json-schema": "^3.24.5" + "zod-to-json-schema": "^3.24.5", + "zod-validation-error": "^4.0.1" }, "bin": { "mcp-server": "dist/index.js" }, "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", "@types/jest": "^29.4.0", + "@types/qs": "^6.14.0", + "@types/yargs": "^17.0.8", "@typescript-eslint/eslint-plugin": "8.31.1", "@typescript-eslint/parser": "8.31.1", "eslint": "^8.49.0", @@ -49,7 +62,7 @@ "ts-jest": "^29.1.0", "ts-morph": "^19.0.0", "ts-node": "^10.5.0", - "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.8/tsc-multi.tgz", + "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz", "tsconfig-paths": "^4.0.0", "typescript": "5.8.3" }, diff --git a/packages/mcp-server/src/code-tool-paths.cts b/packages/mcp-server/src/code-tool-paths.cts new file mode 100644 index 0000000..15ce7f5 --- /dev/null +++ b/packages/mcp-server/src/code-tool-paths.cts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export const workerPath = require.resolve('./code-tool-worker.mjs'); diff --git a/packages/mcp-server/src/code-tool-types.ts b/packages/mcp-server/src/code-tool-types.ts new file mode 100644 index 0000000..409f492 --- /dev/null +++ b/packages/mcp-server/src/code-tool-types.ts @@ -0,0 +1,14 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { ClientOptions } from 'scan-documents'; + +export type WorkerInput = { + opts: ClientOptions; + code: string; +}; +export type WorkerSuccess = { + result: unknown | null; + logLines: string[]; + errLines: string[]; +}; +export type WorkerError = { message: string | undefined }; diff --git a/packages/mcp-server/src/code-tool-worker.ts b/packages/mcp-server/src/code-tool-worker.ts new file mode 100644 index 0000000..11e8851 --- /dev/null +++ b/packages/mcp-server/src/code-tool-worker.ts @@ -0,0 +1,46 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import util from 'node:util'; +import { WorkerInput, WorkerSuccess, WorkerError } from './code-tool-types'; +import { ScanDocuments } from 'scan-documents'; + +const fetch = async (req: Request): Promise => { + const { opts, code } = (await req.json()) as WorkerInput; + const client = new ScanDocuments({ + ...opts, + }); + + const logLines: string[] = []; + const errLines: string[] = []; + const console = { + log: (...args: unknown[]) => { + logLines.push(util.format(...args)); + }, + error: (...args: unknown[]) => { + errLines.push(util.format(...args)); + }, + }; + try { + let run_ = async (client: any) => {}; + eval(` + ${code} + run_ = run; + `); + const result = await run_(client); + return Response.json({ + result, + logLines, + errLines, + } satisfies WorkerSuccess); + } catch (e) { + const message = e instanceof Error ? e.message : undefined; + return Response.json( + { + message, + } satisfies WorkerError, + { status: 400, statusText: 'Code execution error' }, + ); + } +}; + +export default { fetch }; diff --git a/packages/mcp-server/src/code-tool.ts b/packages/mcp-server/src/code-tool.ts new file mode 100644 index 0000000..03452d4 --- /dev/null +++ b/packages/mcp-server/src/code-tool.ts @@ -0,0 +1,144 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { dirname } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import ScanDocuments, { ClientOptions } from 'scan-documents'; +import { Endpoint, ContentBlock, Metadata } from './tools/types'; + +import { Tool } from '@modelcontextprotocol/sdk/types.js'; + +import { newDenoHTTPWorker } from '@valtown/deno-http-worker'; +import { WorkerInput, WorkerError, WorkerSuccess } from './code-tool-types'; +import { workerPath } from './code-tool-paths.cjs'; + +/** + * A tool that runs code against a copy of the SDK. + * + * Instead of exposing every endpoint as it's 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 codeTool(): Endpoint { + const metadata: Metadata = { resource: 'all', operation: 'write', tags: [] }; + const tool: Tool = { + name: 'execute', + description: + 'Runs Typescript code to interact with the API.\nYou are a skilled programmer writing code to interface with the service.\nDefine an async function named "run" that takes a single parameter of an initialized client, and it will be run.\nDo not initialize a client, but instead use the client that you are given as a parameter.\nYou will be returned anything that your function returns, plus the results of any console.log statements.\nIf any code triggers an error, the tool will return an error response, so you do not need to add error handling unless you want to output something more helpful than the raw error.\nIt is not necessary to add comments to code, unless by adding those comments you believe that you can generate better code.\nThis code will run in a container, and you will not be able to use fetch or otherwise interact with the network calls other than through the client you are given.\nAny variables you define won\'t live between successive uses of this call, so make sure to return or log any data you might need later.', + inputSchema: { type: 'object', properties: { code: { type: 'string' } } }, + }; + + const handler = async (client: ScanDocuments, args: unknown) => { + const baseURLHostname = new URL(client.baseURL).hostname; + const { code } = args as { code: string }; + + const worker = await newDenoHTTPWorker(pathToFileURL(workerPath), { + runFlags: [ + `--node-modules-dir=manual`, + `--allow-read=code-tool-worker.mjs,${workerPath.replace(/([\/\\]node_modules)[\/\\].+$/, '$1')}/`, + `--allow-net=${baseURLHostname}`, + // Allow environment variables because instantiating the client will try to read from them, + // even though they are not set. + '--allow-env', + ], + printOutput: true, + spawnOptions: { + cwd: dirname(workerPath), + }, + }); + + try { + const resp = await new Promise((resolve, reject) => { + worker.addEventListener('exit', (exitCode) => { + reject(new Error(`Worker exited with code ${exitCode}`)); + }); + + const opts: ClientOptions = { + baseURL: client.baseURL, + apiKey: client.apiKey, + defaultHeaders: { + 'X-Stainless-MCP': 'true', + }, + }; + + const req = worker.request( + 'http://localhost', + { + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }, + (resp) => { + const body: Uint8Array[] = []; + resp.on('error', (err) => { + reject(err); + }); + resp.on('data', (chunk) => { + body.push(chunk); + }); + resp.on('end', () => { + resolve( + new Response(Buffer.concat(body).toString(), { + status: resp.statusCode ?? 200, + headers: resp.headers as any, + }), + ); + }); + }, + ); + + const body = JSON.stringify({ + opts, + code, + } satisfies WorkerInput); + + req.write(body, (err) => { + if (err !== null && err !== undefined) { + reject(err); + } + }); + + req.end(); + }); + + if (resp.status === 200) { + const { result, logLines, errLines } = (await resp.json()) as WorkerSuccess; + const returnOutput: ContentBlock | null = + result === null ? null + : result === undefined ? null + : { + type: 'text', + text: typeof result === 'string' ? (result as string) : JSON.stringify(result), + }; + const logOutput: ContentBlock | null = + logLines.length === 0 ? + null + : { + type: 'text', + text: logLines.join('\n'), + }; + const errOutput: ContentBlock | null = + errLines.length === 0 ? + null + : { + type: 'text', + text: 'Error output:\n' + errLines.join('\n'), + }; + return { + content: [returnOutput, logOutput, errOutput].filter((block) => block !== null), + }; + } else { + const { message } = (await resp.json()) as WorkerError; + throw new Error(message); + } + } catch (e) { + throw e; + } finally { + worker.terminate(); + } + }; + + return { metadata, tool, handler }; +} diff --git a/packages/mcp-server/src/compat.ts b/packages/mcp-server/src/compat.ts index ff0d6d4..f84053c 100644 --- a/packages/mcp-server/src/compat.ts +++ b/packages/mcp-server/src/compat.ts @@ -1,4 +1,5 @@ import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; import { Endpoint } from './tools'; export interface ClientCapabilities { @@ -19,12 +20,13 @@ export const defaultClientCapabilities: ClientCapabilities = { toolNameLength: undefined, }; -export type ClientType = 'openai-agents' | 'claude' | 'claude-code' | 'cursor'; +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 = { +export const knownClients: Record, ClientCapabilities> = { 'openai-agents': { topLevelUnions: false, validJson: true, @@ -70,8 +72,11 @@ export function parseEmbeddedJSON(args: Record, schema: Record< if (typeof value === 'string') { try { const parsed = JSON.parse(value); - newArgs[key] = parsed; - updated = true; + // 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 } diff --git a/packages/mcp-server/src/filtering.ts b/packages/mcp-server/src/filtering.ts new file mode 100644 index 0000000..1aa9a40 --- /dev/null +++ b/packages/mcp-server/src/filtering.ts @@ -0,0 +1,14 @@ +// @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); +} diff --git a/packages/mcp-server/src/headers.ts b/packages/mcp-server/src/headers.ts new file mode 100644 index 0000000..dd99503 --- /dev/null +++ b/packages/mcp-server/src/headers.ts @@ -0,0 +1,10 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { IncomingMessage } from 'node:http'; +import { ClientOptions } from 'scan-documents'; + +export const parseAuthHeaders = (req: IncomingMessage): Partial => { + const apiKey = + Array.isArray(req.headers['x-api-key']) ? req.headers['x-api-key'][0] : req.headers['x-api-key']; + return { apiKey }; +}; diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts new file mode 100644 index 0000000..c11185b --- /dev/null +++ b/packages/mcp-server/src/http.ts @@ -0,0 +1,115 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +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 { initMcpServer, newMcpServer } from './server'; +import { parseAuthHeaders } from './headers'; + +const newServer = ( + defaultMcpOptions: 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({ + server: server, + clientOptions: { + ...authOptions, + defaultHeaders: { + 'X-Stainless-MCP': 'true', + }, + }, + mcpOptions, + }); + } catch { + res.status(401).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Unauthorized', + }, + }); + return null; + } + + return server; +}; + +const post = (defaultOptions: McpOptions) => async (req: express.Request, res: express.Response) => { + const server = newServer(defaultOptions, req, res); + // If we return null, we already set the authorization error. + if (server === null) return; + const transport = new StreamableHTTPServerTransport({ + // Stateless server + sessionIdGenerator: undefined, + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +}; + +const get = async (req: express.Request, res: express.Response) => { + res.status(405).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not supported', + }, + }); +}; + +const del = async (req: express.Request, res: express.Response) => { + res.status(405).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not supported', + }, + }); +}; + +export const streamableHTTPApp = (options: McpOptions): express.Express => { + const app = express(); + app.set('query parser', 'extended'); + app.use(express.json()); + + app.get('/', get); + app.post('/', post(options)); + app.delete('/', del); + + return app; +}; + +export const launchStreamableHTTPServer = async (options: McpOptions, port: number | string | undefined) => { + const app = streamableHTTPApp(options); + const server = app.listen(port); + const address = server.address(); + + if (typeof address === 'string') { + console.error(`MCP Server running on streamable HTTP at ${address}`); + } else if (address !== null) { + console.error(`MCP Server running on streamable HTTP on port ${address.port}`); + } else { + console.error(`MCP Server running on streamable HTTP on port ${port}`); + } +}; diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 0621357..c450e4b 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1,9 +1,10 @@ #!/usr/bin/env node -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { init, selectTools, server } from './server'; +import { selectTools } from './server'; import { Endpoint, endpoints } from './tools'; -import { McpOptions, parseOptions } from './options'; +import { McpOptions, parseCLIOptions } from './options'; +import { launchStdioServer } from './stdio'; +import { launchStreamableHTTPServer } from './http'; async function main() { const options = parseOptionsOrError(); @@ -13,18 +14,21 @@ async function main() { return; } - const includedTools = selectToolsOrError(endpoints, options); + const selectedTools = selectToolsOrError(endpoints, options); console.error( - `MCP Server starting with ${includedTools.length} tools:`, - includedTools.map((e) => e.tool.name), + `MCP Server starting with ${selectedTools.length} tools:`, + selectedTools.map((e) => e.tool.name), ); - init({ server, endpoints: includedTools }); - - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error('MCP Server running on stdio'); + switch (options.transport) { + case 'stdio': + await launchStdioServer(options); + break; + case 'http': + await launchStreamableHTTPServer(options, options.port ?? options.socket); + break; + } } if (require.main === module) { @@ -36,14 +40,14 @@ if (require.main === module) { function parseOptionsOrError() { try { - return parseOptions(); + return parseCLIOptions(); } catch (error) { console.error('Error parsing options:', error); process.exit(1); } } -function selectToolsOrError(endpoints: Endpoint[], options: McpOptions) { +function selectToolsOrError(endpoints: Endpoint[], options: McpOptions): Endpoint[] { try { const includedTools = selectTools(endpoints, options); if (includedTools.length === 0) { diff --git a/packages/mcp-server/src/options.ts b/packages/mcp-server/src/options.ts index c075101..2100cf5 100644 --- a/packages/mcp-server/src/options.ts +++ b/packages/mcp-server/src/options.ts @@ -1,18 +1,24 @@ +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; - filters: Filter[]; - capabilities?: Partial; + client?: ClientType | undefined; + includeDynamicTools?: boolean | undefined; + includeAllTools?: boolean | undefined; + includeCodeTools?: boolean | undefined; + filters?: Filter[] | undefined; + capabilities?: Partial | undefined; }; const CAPABILITY_CHOICES = [ @@ -44,18 +50,18 @@ function parseCapabilityValue(cap: string): { name: Capability; value?: number } return { name: cap as Capability }; } -export function parseOptions(): CLIOptions { +export function parseCLIOptions(): CLIOptions { const opts = yargs(hideBin(process.argv)) .option('tools', { type: 'string', array: true, - choices: ['dynamic', 'all'], + choices: ['dynamic', 'all', 'code'], description: 'Use dynamic tools or all tools', }) .option('no-tools', { type: 'string', array: true, - choices: ['dynamic', 'all'], + choices: ['dynamic', 'all', 'code'], description: 'Do not use any dynamic or all tools', }) .option('tool', { @@ -129,6 +135,20 @@ export function parseOptions(): CLIOptions { type: 'boolean', description: 'Print detailed explanation of client capabilities and exit', }) + .option('transport', { + type: 'string', + choices: ['stdio', 'http'], + default: 'stdio', + description: 'What transport to use; stdio for local servers or http for remote servers', + }) + .option('port', { + type: 'number', + description: 'Port to serve on if using http transport', + }) + .option('socket', { + type: 'string', + description: 'Unix socket to serve on if using http transport', + }) .help(); for (const [command, desc] of examples()) { @@ -184,14 +204,7 @@ export function parseOptions(): CLIOptions { } // Parse client capabilities - const clientCapabilities: ClientCapabilities = { - topLevelUnions: true, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: undefined, - }; + const clientCapabilities: Partial = {}; // Apply individual capability overrides if (Array.isArray(argv.capability)) { @@ -232,20 +245,144 @@ export function parseOptions(): CLIOptions { } } + const shouldIncludeToolType = (toolType: 'dynamic' | 'all' | 'code') => + explicitTools ? argv.tools?.includes(toolType) && !argv.noTools?.includes(toolType) : undefined; + const explicitTools = Boolean(argv.tools || argv.noTools); - const includeDynamicTools = - explicitTools ? argv.tools?.includes('dynamic') && !argv.noTools?.includes('dynamic') : undefined; - const includeAllTools = - explicitTools ? argv.tools?.includes('all') && !argv.noTools?.includes('all') : undefined; + const includeDynamicTools = shouldIncludeToolType('dynamic'); + const includeAllTools = shouldIncludeToolType('all'); + const includeCodeTools = shouldIncludeToolType('code'); + + const transport = argv.transport as 'stdio' | 'http'; const client = argv.client as ClientType; return { - client: client && knownClients[client] ? client : undefined, + client: client && client !== 'infer' && knownClients[client] ? client : undefined, includeDynamicTools, includeAllTools, + includeCodeTools, filters, capabilities: clientCapabilities, list: argv.list || false, + transport, + port: argv.port, + socket: argv.socket, + }; +} + +const coerceArray = (zodType: T) => + z.preprocess( + (val) => + Array.isArray(val) ? val + : val ? [val] + : val, + z.array(zodType).optional(), + ); + +const QueryOptions = z.object({ + tools: coerceArray(z.enum(['dynamic', 'all'])).describe('Use dynamic tools or all tools'), + no_tools: coerceArray(z.enum(['dynamic', 'all'])).describe('Do not use dynamic tools or all tools'), + 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; + + return { + client: queryOptions.client ?? defaultOptions.client, + includeDynamicTools: dynamicTools, + includeAllTools: allTools, + includeCodeTools: undefined, + filters, + capabilities: clientCapabilities, }; } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index fa19ef1..4a33637 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -3,7 +3,12 @@ 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, Tool } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolRequestSchema, + Implementation, + ListToolsRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; import { ClientOptions } from 'scan-documents'; import ScanDocuments from 'scan-documents'; import { @@ -14,6 +19,7 @@ import { parseEmbeddedJSON, } from './compat'; import { dynamicTools } from './dynamic-tools'; +import { codeTool } from './code-tool'; import { McpOptions } from './options'; export { McpOptions } from './options'; @@ -22,18 +28,17 @@ export { Filter } from './tools'; export { ClientOptions } from 'scan-documents'; export { endpoints } from './tools'; -// Create server instance -export const server = new McpServer( - { - name: 'scan_documents_api', - version: '0.1.0-alpha.5', - }, - { - capabilities: { - tools: {}, +export const newMcpServer = () => + new McpServer( + { + name: 'scan_documents_api', + version: '0.1.0-alpha.6', }, - }, -); + { capabilities: { tools: {}, logging: {} } }, + ); + +// Create server instance +export const server = newMcpServer(); /** * Initializes the provided MCP Server with the given tools and handlers. @@ -41,72 +46,102 @@ export const server = new McpServer( */ export function initMcpServer(params: { server: Server | McpServer; - clientOptions: ClientOptions; - mcpOptions: McpOptions; - endpoints?: { tool: Tool; handler: HandlerFunction }[]; -}) { - const transformedEndpoints = selectTools(endpoints, params.mcpOptions); - const client = new ScanDocuments(params.clientOptions); - const capabilities = { - ...defaultClientCapabilities, - ...(params.mcpOptions.client ? knownClients[params.mcpOptions.client] : params.mcpOptions.capabilities), - }; - init({ server: params.server, client, endpoints: transformedEndpoints, capabilities }); -} - -export function init(params: { - server: Server | McpServer; - client?: ScanDocuments; - endpoints?: { tool: Tool; handler: HandlerFunction }[]; - capabilities?: Partial; + clientOptions?: ClientOptions; + mcpOptions?: McpOptions; }) { const server = params.server instanceof McpServer ? params.server.server : params.server; - const providedEndpoints = params.endpoints || endpoints; + const mcpOptions = params.mcpOptions ?? {}; + + let providedEndpoints: Endpoint[] | null = null; + let endpointMap: Record | null = null; + + const initTools = (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 = selectTools(endpoints, mcpOptions); + endpointMap = Object.fromEntries(providedEndpoints.map((endpoint) => [endpoint.tool.name, endpoint])); + }; - const endpointMap = Object.fromEntries(providedEndpoints.map((endpoint) => [endpoint.tool.name, endpoint])); + const logAtLevel = + (level: 'debug' | 'info' | 'warning' | 'error') => + (message: string, ...rest: unknown[]) => { + void server.sendLoggingMessage({ + level, + data: { message, rest }, + }); + }; + const logger = { + debug: logAtLevel('debug'), + info: logAtLevel('info'), + warn: logAtLevel('warning'), + error: logAtLevel('error'), + }; - const client = params.client || new ScanDocuments({ defaultHeaders: { 'X-Stainless-MCP': 'true' } }); + const client = new ScanDocuments({ + logger, + ...params.clientOptions, + defaultHeaders: { + ...params.clientOptions?.defaultHeaders, + 'X-Stainless-MCP': 'true', + }, + }); server.setRequestHandler(ListToolsRequestSchema, async () => { + if (providedEndpoints === null) { + initTools(server.getClientVersion()); + } return { - tools: providedEndpoints.map((endpoint) => endpoint.tool), + tools: providedEndpoints!.map((endpoint) => endpoint.tool), }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (endpointMap === null) { + initTools(server.getClientVersion()); + } const { name, arguments: args } = request.params; - const endpoint = endpointMap[name]; + const endpoint = endpointMap![name]; if (!endpoint) { throw new Error(`Unknown tool: ${name}`); } - return executeHandler(endpoint.tool, endpoint.handler, client, args, params.capabilities); + return executeHandler(endpoint.tool, endpoint.handler, client, args, mcpOptions.capabilities); }); } /** * Selects the tools to include in the MCP Server based on the provided options. */ -export function selectTools(endpoints: Endpoint[], options: McpOptions) { - const filteredEndpoints = query(options.filters, endpoints); +export function selectTools(endpoints: Endpoint[], options?: McpOptions): Endpoint[] { + const filteredEndpoints = query(options?.filters ?? [], endpoints); let includedTools = filteredEndpoints; if (includedTools.length > 0) { - if (options.includeDynamicTools) { + if (options?.includeDynamicTools) { includedTools = dynamicTools(includedTools); } } else { - if (options.includeAllTools) { + if (options?.includeAllTools) { includedTools = endpoints; - } else if (options.includeDynamicTools) { + } else if (options?.includeDynamicTools) { includedTools = dynamicTools(endpoints); + } else if (options?.includeCodeTools) { + includedTools = [codeTool()]; } else { includedTools = endpoints; } } - const capabilities = { ...defaultClientCapabilities, ...options.capabilities }; + const capabilities = { ...defaultClientCapabilities, ...options?.capabilities }; return applyCompatibilityTransformations(includedTools, capabilities); } @@ -121,7 +156,7 @@ export async function executeHandler( compatibilityOptions?: Partial, ) { const options = { ...defaultClientCapabilities, ...compatibilityOptions }; - if (options.validJson && args) { + 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 new file mode 100644 index 0000000..d902a5b --- /dev/null +++ b/packages/mcp-server/src/stdio.ts @@ -0,0 +1,13 @@ +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { initMcpServer, newMcpServer } from './server'; +import { McpOptions } from './options'; + +export const launchStdioServer = async (options: McpOptions) => { + const server = newMcpServer(); + + initMcpServer({ server, mcpOptions: options }); + + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('MCP Server running on stdio'); +}; diff --git a/packages/mcp-server/src/tools/events/list-events.ts b/packages/mcp-server/src/tools/events/list-events.ts index 6dc693f..5cac1ae 100644 --- a/packages/mcp-server/src/tools/events/list-events.ts +++ b/packages/mcp-server/src/tools/events/list-events.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -28,6 +27,10 @@ export const tool: Tool = { type: 'number', }, }, + required: [], + }, + annotations: { + readOnlyHint: true, }, }; diff --git a/packages/mcp-server/src/tools/files/delete-files.ts b/packages/mcp-server/src/tools/files/delete-files.ts index 55141de..a2a81c5 100644 --- a/packages/mcp-server/src/tools/files/delete-files.ts +++ b/packages/mcp-server/src/tools/files/delete-files.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -25,13 +24,17 @@ export const tool: Tool = { type: 'string', }, }, + required: ['id'], + }, + annotations: { + idempotentHint: true, }, }; export const handler = async (client: ScanDocuments, args: Record | undefined) => { const { id, ...body } = args as any; - await client.files.delete(id); - return asTextContentResult('Successful tool call'); + const response = await client.files.delete(id).asResponse(); + return asTextContentResult(await response.text()); }; export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/files/download-files.ts b/packages/mcp-server/src/tools/files/download-files.ts index 564189c..36790ce 100644 --- a/packages/mcp-server/src/tools/files/download-files.ts +++ b/packages/mcp-server/src/tools/files/download-files.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asBinaryContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asBinaryContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -25,6 +24,10 @@ export const tool: Tool = { type: 'string', }, }, + required: ['id'], + }, + annotations: { + readOnlyHint: true, }, }; diff --git a/packages/mcp-server/src/tools/files/list-files.ts b/packages/mcp-server/src/tools/files/list-files.ts index 4419839..9331697 100644 --- a/packages/mcp-server/src/tools/files/list-files.ts +++ b/packages/mcp-server/src/tools/files/list-files.ts @@ -1,9 +1,9 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { maybeFilter } from 'scan-documents-mcp/filtering'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -17,7 +17,8 @@ export const metadata: Metadata = { export const tool: Tool = { name: 'list_files', - description: 'Retrieves a paginated list of files belonging to the authenticated user.', + 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\nRetrieves a paginated list of files belonging to the authenticated user.\n\n# Response Schema\n```json\n{\n type: 'object',\n properties: {\n data: {\n type: 'array',\n description: 'The list of files',\n items: {\n $ref: '#/$defs/file'\n }\n },\n links: {\n type: 'object',\n properties: {\n next: {\n type: 'string',\n description: 'The URL to the next page of results'\n },\n previous: {\n type: 'string',\n description: 'The URL to the previous page of results'\n }\n },\n required: [ 'next',\n 'previous'\n ]\n }\n },\n required: [ 'data',\n 'links'\n ],\n $defs: {\n file: {\n anyOf: [ {\n type: 'object',\n title: 'Image Response',\n description: 'The response for an image file',\n properties: {\n id: {\n type: 'string',\n description: 'The id of the file'\n },\n created_at: {\n type: 'string',\n description: 'The creation date of the file in ISO format'\n },\n name: {\n type: 'string',\n description: 'The name of the file'\n },\n properties: {\n type: 'object',\n properties: {\n height: {\n type: 'number',\n description: 'The height of the image in pixels'\n },\n size: {\n type: 'number',\n description: 'The size of the image in bytes'\n },\n width: {\n type: 'number',\n description: 'The width of the image in pixels'\n }\n },\n required: [ 'height',\n 'size',\n 'width'\n ]\n },\n task_id: {\n type: 'string',\n description: 'The id of the task that generated this file, if any'\n },\n type: {\n type: 'string',\n description: 'The MIME type of the file',\n enum: [ 'image/png',\n 'image/jpeg',\n 'image/webp'\n ]\n }\n },\n required: [ 'id',\n 'created_at',\n 'name',\n 'properties',\n 'task_id',\n 'type'\n ]\n },\n {\n type: 'object',\n title: 'Document Response',\n description: 'The response for a document file',\n properties: {\n id: {\n type: 'string',\n description: 'The id of the file'\n },\n created_at: {\n type: 'string',\n description: 'The creation date of the file in ISO format'\n },\n name: {\n type: 'string',\n description: 'The name of the file'\n },\n properties: {\n type: 'object',\n properties: {\n page_count: {\n type: 'number',\n description: 'The number of pages in the document'\n },\n size: {\n type: 'number',\n description: 'The size of the document in bytes'\n }\n },\n required: [ 'page_count',\n 'size'\n ]\n },\n task_id: {\n type: 'string',\n description: 'The id of the task that generated this file, if any'\n },\n type: {\n type: 'string',\n description: 'The MIME type of the file',\n enum: [ 'application/pdf'\n ]\n }\n },\n required: [ 'id',\n 'created_at',\n 'name',\n 'properties',\n 'task_id',\n 'type'\n ]\n }\n ],\n title: 'File Response',\n description: 'The response for a file. Properties depend on the file type.'\n }\n }\n}\n```", inputSchema: { type: 'object', properties: { @@ -29,13 +30,23 @@ export const tool: Tool = { type: 'number', description: 'The number of elements to retrieve', }, + 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: ScanDocuments, args: Record | undefined) => { - const body = args as any; - return asTextContentResult(await client.files.list(body)); + const { jq_filter, ...body } = args as any; + return asTextContentResult(await maybeFilter(jq_filter, await client.files.list(body))); }; export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/files/retrieve-files.ts b/packages/mcp-server/src/tools/files/retrieve-files.ts index 9b35a64..131e114 100644 --- a/packages/mcp-server/src/tools/files/retrieve-files.ts +++ b/packages/mcp-server/src/tools/files/retrieve-files.ts @@ -1,9 +1,9 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { maybeFilter } from 'scan-documents-mcp/filtering'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -17,20 +17,31 @@ export const metadata: Metadata = { export const tool: Tool = { name: 'retrieve_files', - description: 'Retrieves the data for a specific file by its ID.', + 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\nRetrieves the data for a specific file by its ID.\n\n# Response Schema\n```json\n{\n $ref: '#/$defs/file',\n $defs: {\n file: {\n anyOf: [ {\n type: 'object',\n title: 'Image Response',\n description: 'The response for an image file',\n properties: {\n id: {\n type: 'string',\n description: 'The id of the file'\n },\n created_at: {\n type: 'string',\n description: 'The creation date of the file in ISO format'\n },\n name: {\n type: 'string',\n description: 'The name of the file'\n },\n properties: {\n type: 'object',\n properties: {\n height: {\n type: 'number',\n description: 'The height of the image in pixels'\n },\n size: {\n type: 'number',\n description: 'The size of the image in bytes'\n },\n width: {\n type: 'number',\n description: 'The width of the image in pixels'\n }\n },\n required: [ 'height',\n 'size',\n 'width'\n ]\n },\n task_id: {\n type: 'string',\n description: 'The id of the task that generated this file, if any'\n },\n type: {\n type: 'string',\n description: 'The MIME type of the file',\n enum: [ 'image/png',\n 'image/jpeg',\n 'image/webp'\n ]\n }\n },\n required: [ 'id',\n 'created_at',\n 'name',\n 'properties',\n 'task_id',\n 'type'\n ]\n },\n {\n type: 'object',\n title: 'Document Response',\n description: 'The response for a document file',\n properties: {\n id: {\n type: 'string',\n description: 'The id of the file'\n },\n created_at: {\n type: 'string',\n description: 'The creation date of the file in ISO format'\n },\n name: {\n type: 'string',\n description: 'The name of the file'\n },\n properties: {\n type: 'object',\n properties: {\n page_count: {\n type: 'number',\n description: 'The number of pages in the document'\n },\n size: {\n type: 'number',\n description: 'The size of the document in bytes'\n }\n },\n required: [ 'page_count',\n 'size'\n ]\n },\n task_id: {\n type: 'string',\n description: 'The id of the task that generated this file, if any'\n },\n type: {\n type: 'string',\n description: 'The MIME type of the file',\n enum: [ 'application/pdf'\n ]\n }\n },\n required: [ 'id',\n 'created_at',\n 'name',\n 'properties',\n 'task_id',\n 'type'\n ]\n }\n ],\n title: 'File Response',\n description: 'The response for a file. Properties depend on the file type.'\n }\n }\n}\n```", inputSchema: { type: 'object', properties: { id: { type: 'string', }, + 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: ['id'], + }, + annotations: { + readOnlyHint: true, }, }; export const handler = async (client: ScanDocuments, args: Record | undefined) => { - const { id, ...body } = args as any; - return asTextContentResult(await client.files.retrieve(id)); + const { id, jq_filter, ...body } = args as any; + return asTextContentResult(await maybeFilter(jq_filter, await client.files.retrieve(id))); }; export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/files/upload-files.ts b/packages/mcp-server/src/tools/files/upload-files.ts index af089e9..772e51c 100644 --- a/packages/mcp-server/src/tools/files/upload-files.ts +++ b/packages/mcp-server/src/tools/files/upload-files.ts @@ -1,9 +1,9 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { maybeFilter } from 'scan-documents-mcp/filtering'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -17,7 +17,8 @@ export const metadata: Metadata = { export const tool: Tool = { name: 'upload_files', - description: "Uploads a file to the user's storage. The file size is limited to 10MB.", + 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\nUploads a file to the user's storage. The file size is limited to 10MB.\n\n# Response Schema\n```json\n{\n $ref: '#/$defs/file',\n $defs: {\n file: {\n anyOf: [ {\n type: 'object',\n title: 'Image Response',\n description: 'The response for an image file',\n properties: {\n id: {\n type: 'string',\n description: 'The id of the file'\n },\n created_at: {\n type: 'string',\n description: 'The creation date of the file in ISO format'\n },\n name: {\n type: 'string',\n description: 'The name of the file'\n },\n properties: {\n type: 'object',\n properties: {\n height: {\n type: 'number',\n description: 'The height of the image in pixels'\n },\n size: {\n type: 'number',\n description: 'The size of the image in bytes'\n },\n width: {\n type: 'number',\n description: 'The width of the image in pixels'\n }\n },\n required: [ 'height',\n 'size',\n 'width'\n ]\n },\n task_id: {\n type: 'string',\n description: 'The id of the task that generated this file, if any'\n },\n type: {\n type: 'string',\n description: 'The MIME type of the file',\n enum: [ 'image/png',\n 'image/jpeg',\n 'image/webp'\n ]\n }\n },\n required: [ 'id',\n 'created_at',\n 'name',\n 'properties',\n 'task_id',\n 'type'\n ]\n },\n {\n type: 'object',\n title: 'Document Response',\n description: 'The response for a document file',\n properties: {\n id: {\n type: 'string',\n description: 'The id of the file'\n },\n created_at: {\n type: 'string',\n description: 'The creation date of the file in ISO format'\n },\n name: {\n type: 'string',\n description: 'The name of the file'\n },\n properties: {\n type: 'object',\n properties: {\n page_count: {\n type: 'number',\n description: 'The number of pages in the document'\n },\n size: {\n type: 'number',\n description: 'The size of the document in bytes'\n }\n },\n required: [ 'page_count',\n 'size'\n ]\n },\n task_id: {\n type: 'string',\n description: 'The id of the task that generated this file, if any'\n },\n type: {\n type: 'string',\n description: 'The MIME type of the file',\n enum: [ 'application/pdf'\n ]\n }\n },\n required: [ 'id',\n 'created_at',\n 'name',\n 'properties',\n 'task_id',\n 'type'\n ]\n }\n ],\n title: 'File Response',\n description: 'The response for a file. Properties depend on the file type.'\n }\n }\n}\n```", inputSchema: { type: 'object', properties: { @@ -29,13 +30,21 @@ export const tool: Tool = { type: 'string', description: 'The name of the file', }, + 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', 'name'], }, + annotations: {}, }; export const handler = async (client: ScanDocuments, args: Record | undefined) => { - const body = args as any; - return asTextContentResult(await client.files.upload(body)); + const { jq_filter, ...body } = args as any; + return asTextContentResult(await maybeFilter(jq_filter, await client.files.upload(body))); }; export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/image-operations/apply-effect-image-operations.ts b/packages/mcp-server/src/tools/image-operations/apply-effect-image-operations.ts index 02c917c..70fce7c 100644 --- a/packages/mcp-server/src/tools/image-operations/apply-effect-image-operations.ts +++ b/packages/mcp-server/src/tools/image-operations/apply-effect-image-operations.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -40,7 +39,9 @@ export const tool: Tool = { description: 'The name of the file', }, }, + required: ['effect', 'input'], }, + annotations: {}, }; export const handler = async (client: ScanDocuments, args: Record | undefined) => { diff --git a/packages/mcp-server/src/tools/image-operations/convert-image-operations.ts b/packages/mcp-server/src/tools/image-operations/convert-image-operations.ts index f642b09..058d677 100644 --- a/packages/mcp-server/src/tools/image-operations/convert-image-operations.ts +++ b/packages/mcp-server/src/tools/image-operations/convert-image-operations.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -43,6 +42,7 @@ export const tool: Tool = { description: 'The name of the file', }, }, + required: ['input', 'target_format'], }, { type: 'object', @@ -70,6 +70,7 @@ export const tool: Tool = { description: 'The name of the file', }, }, + required: ['input', 'quality', 'target_format'], }, { type: 'object', @@ -97,9 +98,11 @@ export const tool: Tool = { description: 'The name of the file', }, }, + required: ['input', 'quality', 'target_format'], }, ], }, + annotations: {}, }; export const handler = async (client: ScanDocuments, args: Record | undefined) => { diff --git a/packages/mcp-server/src/tools/image-operations/detect-documents-image-operations.ts b/packages/mcp-server/src/tools/image-operations/detect-documents-image-operations.ts index 905f20f..274ca13 100644 --- a/packages/mcp-server/src/tools/image-operations/detect-documents-image-operations.ts +++ b/packages/mcp-server/src/tools/image-operations/detect-documents-image-operations.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -31,7 +30,9 @@ export const tool: Tool = { 'The URL to call when the task is completed or failed. If you want to receive events, you probably prefer to use `webhooks` instead.', }, }, + required: ['input'], }, + annotations: {}, }; export const handler = async (client: ScanDocuments, args: Record | undefined) => { diff --git a/packages/mcp-server/src/tools/image-operations/extract-text-image-operations.ts b/packages/mcp-server/src/tools/image-operations/extract-text-image-operations.ts index 2755a04..a647ae5 100644 --- a/packages/mcp-server/src/tools/image-operations/extract-text-image-operations.ts +++ b/packages/mcp-server/src/tools/image-operations/extract-text-image-operations.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -39,6 +38,7 @@ export const tool: Tool = { 'The URL to call when the task is completed or failed. If you want to receive events, you probably prefer to use `webhooks` instead.', }, }, + required: ['format', 'input'], }, { type: 'object', @@ -58,6 +58,7 @@ export const tool: Tool = { 'The URL to call when the task is completed or failed. If you want to receive events, you probably prefer to use `webhooks` instead.', }, }, + required: ['format', 'input'], }, { type: 'object', @@ -77,6 +78,7 @@ export const tool: Tool = { 'The URL to call when the task is completed or failed. If you want to receive events, you probably prefer to use `webhooks` instead.', }, }, + required: ['format', 'input'], }, { type: 'object', @@ -99,6 +101,7 @@ export const tool: Tool = { 'The URL to call when the task is completed or failed. If you want to receive events, you probably prefer to use `webhooks` instead.', }, }, + required: ['format', 'input', 'schema'], }, ], $defs: { @@ -112,6 +115,7 @@ export const tool: Tool = { }, example: { type: 'object', + additionalProperties: true, }, format: { type: 'string', @@ -121,6 +125,7 @@ export const tool: Tool = { }, properties: { type: 'object', + additionalProperties: true, }, required: { type: 'array', @@ -133,10 +138,10 @@ export const tool: Tool = { enum: ['string', 'number', 'integer', 'boolean', 'array', 'object'], }, }, - required: [], }, }, }, + annotations: {}, }; export const handler = async (client: ScanDocuments, args: Record | undefined) => { diff --git a/packages/mcp-server/src/tools/image-operations/scan-image-operations.ts b/packages/mcp-server/src/tools/image-operations/scan-image-operations.ts new file mode 100644 index 0000000..4846e3c --- /dev/null +++ b/packages/mcp-server/src/tools/image-operations/scan-image-operations.ts @@ -0,0 +1,59 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; + +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import ScanDocuments from 'scan-documents'; + +export const metadata: Metadata = { + resource: 'image_operations', + operation: 'write', + tags: [], + httpMethod: 'post', + httpPath: '/v1/image-operations/scan', + operationId: 'scanImage', +}; + +export const tool: Tool = { + name: 'scan_image_operations', + description: + 'Creates a task to scan an image file. \nThis is an equivalent operation for `detect-documents` and `warp` combined, additionally it can apply effects to the scanned image.', + inputSchema: { + type: 'object', + properties: { + effect: { + type: 'string', + description: 'The effect to apply to the image', + enum: ['none', 'grayscale', 'scanner', 'black-background'], + }, + input: { + type: 'string', + description: 'The id of the file or task to operate on.', + }, + scan_mode: { + type: 'string', + description: + "Mode for detecting documents in the image. Available modes are:\n- **none**: No document detection is performed.\n- **standard**: Using a quick algorithm. Document is detected in the image, and the image is cropped to the detected document area fixing the perspective to match the document's shape.", + enum: ['none', 'standard'], + }, + callback_url: { + type: 'string', + description: + 'The URL to call when the task is completed or failed. If you want to receive events, you probably prefer to use `webhooks` instead.', + }, + name: { + type: 'string', + description: 'The name of the file', + }, + }, + required: ['effect', 'input', 'scan_mode'], + }, + annotations: {}, +}; + +export const handler = async (client: ScanDocuments, args: Record | undefined) => { + const body = args as any; + return asTextContentResult(await client.imageOperations.scan(body)); +}; + +export default { metadata, tool, handler }; diff --git a/packages/mcp-server/src/tools/image-operations/warp-image-operations.ts b/packages/mcp-server/src/tools/image-operations/warp-image-operations.ts index 90d6c92..5f8ed48 100644 --- a/packages/mcp-server/src/tools/image-operations/warp-image-operations.ts +++ b/packages/mcp-server/src/tools/image-operations/warp-image-operations.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -31,6 +30,7 @@ export const tool: Tool = { description: 'Coordinates of the 4 vertices of the quadrilateral to warp the image to.', items: { type: 'object', + additionalProperties: true, }, }, callback_url: { @@ -43,7 +43,9 @@ export const tool: Tool = { description: 'The name of the file', }, }, + required: ['input', 'vertices'], }, + annotations: {}, }; export const handler = async (client: ScanDocuments, args: Record | undefined) => { diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts index 972f773..f862226 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -16,6 +16,7 @@ import apply_effect_image_operations from './image-operations/apply-effect-image import convert_image_operations from './image-operations/convert-image-operations'; import detect_documents_image_operations from './image-operations/detect-documents-image-operations'; import extract_text_image_operations from './image-operations/extract-text-image-operations'; +import scan_image_operations from './image-operations/scan-image-operations'; import warp_image_operations from './image-operations/warp-image-operations'; import extract_pages_pdf_operations from './pdf-operations/extract-pages-pdf-operations'; import merge_pdf_operations from './pdf-operations/merge-pdf-operations'; @@ -40,6 +41,7 @@ addEndpoint(apply_effect_image_operations); addEndpoint(convert_image_operations); addEndpoint(detect_documents_image_operations); addEndpoint(extract_text_image_operations); +addEndpoint(scan_image_operations); addEndpoint(warp_image_operations); addEndpoint(extract_pages_pdf_operations); addEndpoint(merge_pdf_operations); diff --git a/packages/mcp-server/src/tools/pdf-operations/extract-pages-pdf-operations.ts b/packages/mcp-server/src/tools/pdf-operations/extract-pages-pdf-operations.ts index bee5b28..2f2d630 100644 --- a/packages/mcp-server/src/tools/pdf-operations/extract-pages-pdf-operations.ts +++ b/packages/mcp-server/src/tools/pdf-operations/extract-pages-pdf-operations.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -39,7 +38,9 @@ export const tool: Tool = { description: 'The name of the file', }, }, + required: ['input', 'pages'], }, + annotations: {}, }; export const handler = async (client: ScanDocuments, args: Record | undefined) => { diff --git a/packages/mcp-server/src/tools/pdf-operations/merge-pdf-operations.ts b/packages/mcp-server/src/tools/pdf-operations/merge-pdf-operations.ts index 2aeeab2..343f025 100644 --- a/packages/mcp-server/src/tools/pdf-operations/merge-pdf-operations.ts +++ b/packages/mcp-server/src/tools/pdf-operations/merge-pdf-operations.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -39,7 +38,9 @@ export const tool: Tool = { description: 'The name of the file', }, }, + required: ['input'], }, + annotations: {}, }; export const handler = async (client: ScanDocuments, args: Record | undefined) => { diff --git a/packages/mcp-server/src/tools/pdf-operations/render-pdf-operations.ts b/packages/mcp-server/src/tools/pdf-operations/render-pdf-operations.ts index 6629703..5dc6671 100644 --- a/packages/mcp-server/src/tools/pdf-operations/render-pdf-operations.ts +++ b/packages/mcp-server/src/tools/pdf-operations/render-pdf-operations.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -43,7 +42,9 @@ export const tool: Tool = { description: 'Page range (e.g., 2-7), a comma-separated list (e.g., 2,3,7) of pages.', }, }, + required: ['input'], }, + annotations: {}, }; export const handler = async (client: ScanDocuments, args: Record | undefined) => { diff --git a/packages/mcp-server/src/tools/pdf-operations/split-pdf-operations.ts b/packages/mcp-server/src/tools/pdf-operations/split-pdf-operations.ts index 89956cd..4d77190 100644 --- a/packages/mcp-server/src/tools/pdf-operations/split-pdf-operations.ts +++ b/packages/mcp-server/src/tools/pdf-operations/split-pdf-operations.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -35,7 +34,9 @@ export const tool: Tool = { description: 'The name of the file', }, }, + required: ['input'], }, + annotations: {}, }; export const handler = async (client: ScanDocuments, args: Record | undefined) => { diff --git a/packages/mcp-server/src/tools/tasks/list-tasks.ts b/packages/mcp-server/src/tools/tasks/list-tasks.ts index d57788e..62db276 100644 --- a/packages/mcp-server/src/tools/tasks/list-tasks.ts +++ b/packages/mcp-server/src/tools/tasks/list-tasks.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -28,6 +27,10 @@ export const tool: Tool = { type: 'number', }, }, + required: [], + }, + annotations: { + readOnlyHint: true, }, }; diff --git a/packages/mcp-server/src/tools/tasks/retrieve-tasks.ts b/packages/mcp-server/src/tools/tasks/retrieve-tasks.ts index 071d89f..5c1e48c 100644 --- a/packages/mcp-server/src/tools/tasks/retrieve-tasks.ts +++ b/packages/mcp-server/src/tools/tasks/retrieve-tasks.ts @@ -1,9 +1,8 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { asTextContentResult } from 'scan-documents-mcp/tools/types'; +import { Metadata, asTextContentResult } from 'scan-documents-mcp/tools/types'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { Metadata } from '../'; import ScanDocuments from 'scan-documents'; export const metadata: Metadata = { @@ -26,6 +25,10 @@ export const tool: Tool = { description: 'The id of the task to get.', }, }, + required: ['id'], + }, + annotations: { + readOnlyHint: true, }, }; diff --git a/packages/mcp-server/src/tools/types.ts b/packages/mcp-server/src/tools/types.ts index d3d1169..ddff304 100644 --- a/packages/mcp-server/src/tools/types.ts +++ b/packages/mcp-server/src/tools/types.ts @@ -47,7 +47,7 @@ export type HandlerFunction = ( args: Record | undefined, ) => Promise; -export function asTextContentResult(result: Object): ToolCallResult { +export function asTextContentResult(result: unknown): ToolCallResult { return { content: [ { diff --git a/packages/mcp-server/tests/options.test.ts b/packages/mcp-server/tests/options.test.ts index f7661d6..a8a5b81 100644 --- a/packages/mcp-server/tests/options.test.ts +++ b/packages/mcp-server/tests/options.test.ts @@ -1,5 +1,6 @@ -import { parseOptions } from '../src/options'; +import { parseCLIOptions, parseQueryOptions } from '../src/options'; import { Filter } from '../src/tools'; +import { parseEmbeddedJSON } from '../src/compat'; // Mock process.argv const mockArgv = (args: string[]) => { @@ -10,7 +11,7 @@ const mockArgv = (args: string[]) => { }; }; -describe('parseOptions', () => { +describe('parseCLIOptions', () => { it('should parse basic filter options', () => { const cleanup = mockArgv([ '--tool=test-tool', @@ -19,7 +20,7 @@ describe('parseOptions', () => { '--tag=test-tag', ]); - const result = parseOptions(); + const result = parseCLIOptions(); expect(result.filters).toEqual([ { type: 'tag', op: 'include', value: 'test-tag' }, @@ -28,15 +29,7 @@ describe('parseOptions', () => { { type: 'operation', op: 'include', value: 'read' }, ] as Filter[]); - // Default client capabilities - expect(result.capabilities).toEqual({ - topLevelUnions: true, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: undefined, - }); + expect(result.capabilities).toEqual({}); expect(result.list).toBe(false); @@ -51,7 +44,7 @@ describe('parseOptions', () => { '--no-tag=exclude-tag', ]); - const result = parseOptions(); + const result = parseCLIOptions(); expect(result.filters).toEqual([ { type: 'tag', op: 'exclude', value: 'exclude-tag' }, @@ -60,14 +53,7 @@ describe('parseOptions', () => { { type: 'operation', op: 'exclude', value: 'write' }, ] as Filter[]); - expect(result.capabilities).toEqual({ - topLevelUnions: true, - validJson: true, - refs: true, - unions: true, - formats: true, - toolNameLength: undefined, - }); + expect(result.capabilities).toEqual({}); cleanup(); }); @@ -75,7 +61,7 @@ describe('parseOptions', () => { it('should parse client presets', () => { const cleanup = mockArgv(['--client=openai-agents']); - const result = parseOptions(); + const result = parseCLIOptions(); expect(result.client).toEqual('openai-agents'); @@ -91,14 +77,13 @@ describe('parseOptions', () => { '--capability=tool-name-length=40', ]); - const result = parseOptions(); + const result = parseCLIOptions(); expect(result.capabilities).toEqual({ topLevelUnions: true, validJson: true, refs: true, unions: true, - formats: true, toolNameLength: 40, }); @@ -108,7 +93,7 @@ describe('parseOptions', () => { it('should handle list option', () => { const cleanup = mockArgv(['--list']); - const result = parseOptions(); + const result = parseCLIOptions(); expect(result.list).toBe(true); @@ -118,7 +103,7 @@ describe('parseOptions', () => { it('should handle multiple filters of the same type', () => { const cleanup = mockArgv(['--tool=tool1', '--tool=tool2', '--resource=res1', '--resource=res2']); - const result = parseOptions(); + const result = parseCLIOptions(); expect(result.filters).toEqual([ { type: 'resource', op: 'include', value: 'res1' }, @@ -137,7 +122,7 @@ describe('parseOptions', () => { '--capability=top-level-unions,valid-json,unions', ]); - const result = parseOptions(); + const result = parseCLIOptions(); expect(result.filters).toEqual([ { type: 'resource', op: 'include', value: 'res1' }, @@ -149,10 +134,7 @@ describe('parseOptions', () => { expect(result.capabilities).toEqual({ topLevelUnions: true, validJson: true, - refs: true, unions: true, - formats: true, - toolNameLength: undefined, }); cleanup(); @@ -165,7 +147,7 @@ describe('parseOptions', () => { const originalError = console.error; console.error = jest.fn(); - expect(() => parseOptions()).toThrow(); + expect(() => parseCLIOptions()).toThrow(); console.error = originalError; cleanup(); @@ -178,9 +160,359 @@ describe('parseOptions', () => { const originalError = console.error; console.error = jest.fn(); - expect(() => parseOptions()).toThrow(); + expect(() => parseCLIOptions()).toThrow(); console.error = originalError; cleanup(); }); }); + +describe('parseQueryOptions', () => { + const defaultOptions = { + client: undefined, + includeDynamicTools: 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 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', () => { + const query = ''; + const result = parseQueryOptions(defaultOptions, query); + + expect(result).toEqual(defaultOptions); + }); + + it('should handle invalid query string gracefully', () => { + const query = 'invalid=value&operation=invalid-operation'; + + // 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' }, + ]); + }); +}); + +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/yarn.lock b/packages/mcp-server/yarn.lock index 9970ec3..707a2de 100644 --- a/packages/mcp-server/yarn.lock +++ b/packages/mcp-server/yarn.lock @@ -584,15 +584,17 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@modelcontextprotocol/sdk@^1.6.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.11.1.tgz#c7f4a1432872ef10130f5d9b0072060c17a3946b" - integrity sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ== +"@modelcontextprotocol/sdk@^1.11.5": + version "1.17.3" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.17.3.tgz#cf92354220f0183d28179e96a9bf3a8f6d3211ae" + integrity sha512-JPwUKWSsbzx+DLFznf/QZ32Qa+ptfbUlHhRLrBQBAFu9iI1iYvizM4p+zhhRDceSsPutXp4z+R/HPVphlIiclg== dependencies: + ajv "^6.12.6" content-type "^1.0.5" cors "^2.8.5" - cross-spawn "^7.0.3" + cross-spawn "^7.0.5" eventsource "^3.0.2" + eventsource-parser "^3.0.0" express "^5.0.1" express-rate-limit "^7.5.0" pkce-challenge "^5.0.0" @@ -708,6 +710,40 @@ dependencies: "@babel/types" "^7.20.7" +"@types/body-parser@*": + version "1.19.6" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.6.tgz#1859bebb8fd7dac9918a45d54c1971ab8b5af474" + integrity sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + +"@types/express-serve-static-core@^5.0.0": + version "5.0.7" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz#2fa94879c9d46b11a5df4c74ac75befd6b283de6" + integrity sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@^5.0.3": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.3.tgz#6c4bc6acddc2e2a587142e1d8be0bce20757e956" + integrity sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^5.0.0" + "@types/serve-static" "*" + "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" @@ -715,6 +751,11 @@ dependencies: "@types/node" "*" +"@types/http-errors@*": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472" + integrity sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" @@ -742,6 +783,11 @@ expect "^29.0.0" pretty-format "^29.0.0" +"@types/mime@^1": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" + integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== + "@types/node@*": version "22.15.17" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.17.tgz#355ccec95f705b664e4332bb64a7f07db30b7055" @@ -749,6 +795,33 @@ dependencies: undici-types "~6.21.0" +"@types/qs@*": + version "6.14.0" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.14.0.tgz#d8b60cecf62f2db0fb68e5e006077b9178b85de5" + integrity sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ== + +"@types/range-parser@*": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" + integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== + +"@types/send@*": + version "0.17.5" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.5.tgz#d991d4f2b16f2b1ef497131f00a9114290791e74" + integrity sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@types/serve-static@*": + version "1.15.8" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.8.tgz#8180c3fbe4a70e8f00b9f70b9ba7f08f35987877" + integrity sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg== + dependencies: + "@types/http-errors" "*" + "@types/node" "*" + "@types/send" "*" + "@types/stack-utils@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" @@ -885,7 +958,7 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" -ajv@^6.12.4: +ajv@^6.12.4, ajv@^6.12.6: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -1246,7 +1319,7 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.5: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -1514,6 +1587,11 @@ etag@^1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== +eventsource-parser@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.3.tgz#e9af1d40b77e6268cdcbc767321e8b9f066adea8" + integrity sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA== + eventsource-parser@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.1.tgz#5e358dba9a55ba64ca90da883c4ca35bd82467bd" @@ -1562,7 +1640,7 @@ express-rate-limit@^7.5.0: resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-7.5.0.tgz#6a67990a724b4fbbc69119419feef50c51e8b28f" integrity sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg== -express@^5.0.1: +express@^5.0.1, express@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/express/-/express-5.1.0.tgz#d31beaf715a0016f0d53f47d3b4d7acf28c75cc9" integrity sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA== @@ -2404,6 +2482,10 @@ jest@^29.4.0: import-local "^3.0.2" jest-cli "^29.7.0" +"jq-web@https://github.com/stainless-api/jq-web/releases/download/v0.8.6/jq-web.tar.gz": + version "0.8.6" + resolved "https://github.com/stainless-api/jq-web/releases/download/v0.8.6/jq-web.tar.gz#14d0e126987736e82e964d675c3838b5944faa6f" + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3305,9 +3387,9 @@ ts-node@^10.5.0: v8-compile-cache-lib "^3.0.1" yn "3.1.1" -"tsc-multi@https://github.com/stainless-api/tsc-multi/releases/download/v1.1.7/tsc-multi.tgz": - version "1.1.7" - resolved "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.7/tsc-multi.tgz#52f40adf8b808bd0b633346d11cc4a8aeea465cd" +"tsc-multi@https://github.com/stainless-api/tsc-multi/releases/download/v1.1.8/tsc-multi.tgz": + version "1.1.8" + resolved "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.8/tsc-multi.tgz#f544b359b8f05e607771ffacc280e58201476b04" dependencies: debug "^4.3.7" fast-glob "^3.3.2" @@ -3508,7 +3590,17 @@ zod-to-json-schema@^3.24.1, zod-to-json-schema@^3.24.5: resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz#d1095440b147fb7c2093812a53c54df8d5df50a3" integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g== -zod@^3.23.8, zod@^3.24.4: +zod-validation-error@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.1.tgz#a105723eb40299578a6a38cb86647068f6d005b1" + integrity sha512-F3rdaCOHs5ViJ5YTz5zzRtfkQdMdIeKudJAoxy7yB/2ZMEHw73lmCAcQw11r7++20MyGl4WV59EVh7A9rNAyog== + +zod@^3.23.8: version "3.24.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.4.tgz#e2e2cca5faaa012d76e527d0d36622e0a90c315f" integrity sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg== + +zod@^3.25.20: + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== diff --git a/scripts/bootstrap b/scripts/bootstrap index 0af58e2..062a034 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -15,4 +15,4 @@ echo "==> Installing Node dependencies…" PACKAGE_MANAGER=$(command -v yarn >/dev/null 2>&1 && echo "yarn" || echo "npm") -$PACKAGE_MANAGER install +$PACKAGE_MANAGER install "$@" diff --git a/scripts/mock b/scripts/mock index d2814ae..0b28f6e 100755 --- a/scripts/mock +++ b/scripts/mock @@ -21,7 +21,7 @@ echo "==> Starting mock server with URL ${URL}" # Run prism mock on the given spec if [ "$1" == "--daemon" ]; then - npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" &> .prism.log & + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & # Wait for server to come online echo -n "Waiting for server" @@ -37,5 +37,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" fi diff --git a/scripts/test b/scripts/test index 2049e31..7bce051 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! prism_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the prism command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stoplight/prism-cli@~5.3.2 -- prism mock path/to/your.openapi.yml${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" echo exit 1 diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh index d89492a..a6a7b81 100755 --- a/scripts/utils/upload-artifact.sh +++ b/scripts/utils/upload-artifact.sh @@ -12,7 +12,7 @@ if [[ "$SIGNED_URL" == "null" ]]; then exit 1 fi -UPLOAD_RESPONSE=$(tar -cz dist | curl -v -X PUT \ +UPLOAD_RESPONSE=$(tar -cz "${BUILD_PATH:-dist}" | curl -v -X PUT \ -H "Content-Type: application/gzip" \ --data-binary @- "$SIGNED_URL" 2>&1) diff --git a/src/client.ts b/src/client.ts index ce47ee9..bcadfe9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -32,8 +32,10 @@ import { ImageOperationConvertParams, ImageOperationDetectDocumentsParams, ImageOperationExtractTextParams, + ImageOperationScanParams, ImageOperationWarpParams, ImageOperations, + ScanResponse, WarpRequest, WarpResponse, } from './resources/image-operations'; @@ -82,6 +84,8 @@ export interface ClientOptions { * * Note that request timeouts are retried by default, so in a worst-case scenario you may wait * much longer than this timeout before the promise succeeds or fails. + * + * @unit milliseconds */ timeout?: number | undefined; /** @@ -207,7 +211,7 @@ export class ScanDocuments { * Create a new client instance re-using the same options given to the current client with optional overriding. */ withOptions(options: Partial): this { - return new (this.constructor as any as new (props: ClientOptions) => typeof this)({ + const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({ ...this._options, baseURL: this.baseURL, maxRetries: this.maxRetries, @@ -219,6 +223,7 @@ export class ScanDocuments { apiKey: this.apiKey, ...options, }); + return client; } /** @@ -236,7 +241,7 @@ export class ScanDocuments { return; } - protected authHeaders(opts: FinalRequestOptions): NullableHeaders | undefined { + protected async authHeaders(opts: FinalRequestOptions): Promise { return buildHeaders([{ 'x-api-key': this.apiKey }]); } @@ -368,7 +373,9 @@ export class ScanDocuments { await this.prepareOptions(options); - const { req, url, timeout } = this.buildRequest(options, { retryCount: maxRetries - retriesRemaining }); + const { req, url, timeout } = await this.buildRequest(options, { + retryCount: maxRetries - retriesRemaining, + }); await this.prepareRequest(req, { url, options }); @@ -446,7 +453,7 @@ export class ScanDocuments { } with status ${response.status} in ${headersTime - startTime}ms`; if (!response.ok) { - const shouldRetry = this.shouldRetry(response); + const shouldRetry = await this.shouldRetry(response); if (retriesRemaining && shouldRetry) { const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; @@ -545,7 +552,7 @@ export class ScanDocuments { } } - private shouldRetry(response: Response): boolean { + private async shouldRetry(response: Response): Promise { // Note this is not a standard header. const shouldRetryHeader = response.headers.get('x-should-retry'); @@ -622,10 +629,10 @@ export class ScanDocuments { return sleepSeconds * jitter * 1000; } - buildRequest( + async buildRequest( inputOptions: FinalRequestOptions, { retryCount = 0 }: { retryCount?: number } = {}, - ): { req: FinalizedRequestInit; url: string; timeout: number } { + ): Promise<{ req: FinalizedRequestInit; url: string; timeout: number }> { const options = { ...inputOptions }; const { method, path, query, defaultBaseURL } = options; @@ -633,7 +640,7 @@ export class ScanDocuments { if ('timeout' in options) validatePositiveInteger('timeout', options.timeout); options.timeout = options.timeout ?? this.timeout; const { bodyHeaders, body } = this.buildBody({ options }); - const reqHeaders = this.buildHeaders({ options: inputOptions, method, bodyHeaders, retryCount }); + const reqHeaders = await this.buildHeaders({ options: inputOptions, method, bodyHeaders, retryCount }); const req: FinalizedRequestInit = { method, @@ -649,7 +656,7 @@ export class ScanDocuments { return { req, url, timeout: options.timeout }; } - private buildHeaders({ + private async buildHeaders({ options, method, bodyHeaders, @@ -659,7 +666,7 @@ export class ScanDocuments { method: HTTPMethod; bodyHeaders: HeadersLike; retryCount: number; - }): Headers { + }): Promise { let idempotencyHeaders: HeadersLike = {}; if (this.idempotencyHeader && method !== 'get') { if (!options.idempotencyKey) options.idempotencyKey = this.defaultIdempotencyKey(); @@ -675,7 +682,7 @@ export class ScanDocuments { ...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}), ...getPlatformHeaders(), }, - this.authHeaders(options), + await this.authHeaders(options), this._options.defaultHeaders, bodyHeaders, options.headers, @@ -703,7 +710,7 @@ export class ScanDocuments { // Preserve legacy string encoding behavior for now headers.values.has('content-type')) || // `Blob` is superset of `File` - body instanceof Blob || + ((globalThis as any).Blob && body instanceof (globalThis as any).Blob) || // `FormData` -> `multipart/form-data` body instanceof FormData || // `URLSearchParams` -> `application/x-www-form-urlencoded` @@ -748,11 +755,13 @@ export class ScanDocuments { imageOperations: API.ImageOperations = new API.ImageOperations(this); pdfOperations: API.PdfOperations = new API.PdfOperations(this); } + ScanDocuments.Files = Files; ScanDocuments.Tasks = Tasks; ScanDocuments.Events = Events; ScanDocuments.ImageOperations = ImageOperations; ScanDocuments.PdfOperations = PdfOperations; + export declare namespace ScanDocuments { export type RequestOptions = Opts.RequestOptions; @@ -788,12 +797,14 @@ export declare namespace ScanDocuments { type ExtractTextRequest as ExtractTextRequest, type ExtractTextResponse as ExtractTextResponse, type ImageFromTaskResponse as ImageFromTaskResponse, + type ScanResponse as ScanResponse, type WarpRequest as WarpRequest, type WarpResponse as WarpResponse, type ImageOperationApplyEffectParams as ImageOperationApplyEffectParams, type ImageOperationConvertParams as ImageOperationConvertParams, type ImageOperationDetectDocumentsParams as ImageOperationDetectDocumentsParams, type ImageOperationExtractTextParams as ImageOperationExtractTextParams, + type ImageOperationScanParams as ImageOperationScanParams, type ImageOperationWarpParams as ImageOperationWarpParams, }; diff --git a/src/internal/request-options.ts b/src/internal/request-options.ts index 7de032f..2aabf9a 100644 --- a/src/internal/request-options.ts +++ b/src/internal/request-options.ts @@ -9,17 +9,70 @@ import { type HeadersLike } from './headers'; export type FinalRequestOptions = RequestOptions & { method: HTTPMethod; path: string }; export type RequestOptions = { + /** + * The HTTP method for the request (e.g., 'get', 'post', 'put', 'delete'). + */ method?: HTTPMethod; + + /** + * The URL path for the request. + * + * @example "/v1/foo" + */ path?: string; + + /** + * Query parameters to include in the request URL. + */ query?: object | undefined | null; + + /** + * The request body. Can be a string, JSON object, FormData, or other supported types. + */ body?: unknown; + + /** + * HTTP headers to include with the request. Can be a Headers object, plain object, or array of tuples. + */ headers?: HeadersLike; + + /** + * The maximum number of times that the client will retry a request in case of a + * temporary failure, like a network error or a 5XX error from the server. + * + * @default 2 + */ maxRetries?: number; + stream?: boolean | undefined; + + /** + * The maximum amount of time (in milliseconds) that the client should wait for a response + * from the server before timing out a single request. + * + * @unit milliseconds + */ timeout?: number; + + /** + * Additional `RequestInit` options to be passed to the underlying `fetch` call. + * These options will be merged with the client's default fetch options. + */ fetchOptions?: MergedRequestInit; + + /** + * An AbortSignal that can be used to cancel the request. + */ signal?: AbortSignal | undefined | null; + + /** + * A unique key for this request to enable idempotency. + */ idempotencyKey?: string; + + /** + * Override the default base URL for this specific request. + */ defaultBaseURL?: string | undefined; __binaryResponse?: boolean | undefined; diff --git a/src/internal/types.ts b/src/internal/types.ts index d7928cd..b668dfc 100644 --- a/src/internal/types.ts +++ b/src/internal/types.ts @@ -7,7 +7,7 @@ export type KeysEnum = { [P in keyof Required]: true }; export type FinalizedRequestInit = RequestInit & { headers: Headers }; -type NotAny = [unknown] extends [T] ? never : T; +type NotAny = [0] extends [1 & T] ? never : T; /** * Some environments overload the global fetch function, and Parameters only gets the last signature. @@ -64,13 +64,15 @@ type OverloadedParameters = * [1]: https://www.typescriptlang.org/tsconfig/#typeAcquisition */ /** @ts-ignore For users with \@types/node */ -type UndiciTypesRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +type UndiciTypesRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users with undici */ -type UndiciRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +type UndiciRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users with \@types/bun */ type BunRequestInit = globalThis.FetchRequestInit; -/** @ts-ignore For users with node-fetch */ -type NodeFetchRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +/** @ts-ignore For users with node-fetch@2 */ +type NodeFetch2RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +/** @ts-ignore For users with node-fetch@3, doesn't need file extension because types are at ./@types/index.d.ts */ +type NodeFetch3RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users who use Deno */ type FetchRequestInit = NonNullable[1]>; /* eslint-enable */ @@ -79,7 +81,8 @@ type RequestInits = | NotAny | NotAny | NotAny - | NotAny + | NotAny + | NotAny | NotAny | NotAny; diff --git a/src/internal/uploads.ts b/src/internal/uploads.ts index ce181b0..3f8d966 100644 --- a/src/internal/uploads.ts +++ b/src/internal/uploads.ts @@ -90,7 +90,7 @@ export const multipartFormRequestOptions = async ( return { ...opts, body: await createForm(opts.body, fetch) }; }; -const supportsFormDataMap = /** @__PURE__ */ new WeakMap>(); +const supportsFormDataMap = /* @__PURE__ */ new WeakMap>(); /** * node-fetch doesn't support the global FormData object in recent node versions. Instead of sending diff --git a/src/internal/utils/log.ts b/src/internal/utils/log.ts index c3b661f..1eac140 100644 --- a/src/internal/utils/log.ts +++ b/src/internal/utils/log.ts @@ -58,7 +58,7 @@ const noopLogger = { debug: noop, }; -let cachedLoggers = /** @__PURE__ */ new WeakMap(); +let cachedLoggers = /* @__PURE__ */ new WeakMap(); export function loggerFor(client: ScanDocuments): Logger { const logger = client.logger; diff --git a/src/internal/utils/path.ts b/src/internal/utils/path.ts index fd17c83..ae6b727 100644 --- a/src/internal/utils/path.ts +++ b/src/internal/utils/path.ts @@ -12,25 +12,43 @@ export function encodeURIPath(str: string) { return str.replace(/[^A-Za-z0-9\-._~!$&'()*+,;=:@]+/g, encodeURIComponent); } +const EMPTY = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.create(null)); + export const createPathTagFunction = (pathEncoder = encodeURIPath) => function path(statics: readonly string[], ...params: readonly unknown[]): string { // If there are no params, no processing is needed. if (statics.length === 1) return statics[0]!; let postPath = false; + const invalidSegments = []; const path = statics.reduce((previousValue, currentValue, index) => { if (/[?#]/.test(currentValue)) { postPath = true; } - return ( - previousValue + - currentValue + - (index === params.length ? '' : (postPath ? encodeURIComponent : pathEncoder)(String(params[index]))) - ); + const value = params[index]; + let encoded = (postPath ? encodeURIComponent : pathEncoder)('' + value); + if ( + index !== params.length && + (value == null || + (typeof value === 'object' && + // handle values from other realms + value.toString === + Object.getPrototypeOf(Object.getPrototypeOf((value as any).hasOwnProperty ?? EMPTY) ?? EMPTY) + ?.toString)) + ) { + encoded = value + ''; + invalidSegments.push({ + start: previousValue.length + currentValue.length, + length: encoded.length, + error: `Value of type ${Object.prototype.toString + .call(value) + .slice(8, -1)} is not a valid path parameter`, + }); + } + return previousValue + currentValue + (index === params.length ? '' : encoded); }, ''); const pathOnly = path.split(/[?#]/, 1)[0]!; - const invalidSegments = []; const invalidSegmentPattern = /(?<=^|\/)(?:\.|%2e){1,2}(?=\/|$)/gi; let match; @@ -39,9 +57,12 @@ export const createPathTagFunction = (pathEncoder = encodeURIPath) => invalidSegments.push({ start: match.index, length: match[0].length, + error: `Value "${match[0]}" can\'t be safely passed as a path parameter`, }); } + invalidSegments.sort((a, b) => a.start - b.start); + if (invalidSegments.length > 0) { let lastEnd = 0; const underline = invalidSegments.reduce((acc, segment) => { @@ -52,7 +73,9 @@ export const createPathTagFunction = (pathEncoder = encodeURIPath) => }, ''); throw new ScanDocumentsError( - `Path parameters result in path with invalid segments:\n${path}\n${underline}`, + `Path parameters result in path with invalid segments:\n${invalidSegments + .map((e) => e.error) + .join('\n')}\n${path}\n${underline}`, ); } diff --git a/src/resources/image-operations.ts b/src/resources/image-operations.ts index f1cfc32..099a24c 100644 --- a/src/resources/image-operations.ts +++ b/src/resources/image-operations.ts @@ -79,6 +79,24 @@ export class ImageOperations extends APIResource { return this._client.post('/v1/image-operations/extract-text', { body, ...options }); } + /** + * Creates a task to scan an image file. This is an equivalent operation for + * `detect-documents` and `warp` combined, additionally it can apply effects to the + * scanned image. + * + * @example + * ```ts + * const scanResponse = await client.imageOperations.scan({ + * effect: 'none', + * input: 'file_avyrvozb9302uwhq', + * scan_mode: 'standard', + * }); + * ``` + */ + scan(body: ImageOperationScanParams, options?: RequestOptions): APIPromise { + return this._client.post('/v1/image-operations/scan', { body, ...options }); + } + /** * Creates a task to apply perspective correction (warp) to an image based on * detected document boundaries. @@ -1174,6 +1192,311 @@ export namespace ImageFromTaskResponse { } } +/** + * The response of an scan task + */ +export type ScanResponse = + | ScanResponse.CompletedScanTaskResponse + | ScanResponse.PendingScanTaskResponse + | ScanResponse.ProcessingScanTaskResponse + | ScanResponse.FailedScanTaskResponse; + +export namespace ScanResponse { + export interface CompletedScanTaskResponse { + /** + * The unique identifier for the task. + */ + id: string; + + /** + * The URL to which the task result will be sent upon completion or failure. + */ + callback_url: string | null; + + /** + * The creation date of the task in ISO format. + */ + created_at: string; + + /** + * The type of operation being performed by the task. + */ + operation: 'scan'; + + parameters: CompletedScanTaskResponse.Parameters; + + result: CompletedScanTaskResponse.Result; + + /** + * The current status of the task. + */ + status: 'completed'; + + /** + * The last update date of the task in ISO format. + */ + updated_at: string; + } + + export namespace CompletedScanTaskResponse { + export interface Parameters { + /** + * The effect to apply to the image + */ + effect: 'none' | 'grayscale' | 'scanner' | 'black-background'; + + /** + * The id of the file or task to operate on. + */ + input: string; + + /** + * Mode for detecting documents in the image. Available modes are: + * + * - **none**: No document detection is performed. + * - **standard**: Using a quick algorithm. Document is detected in the image, and + * the image is cropped to the detected document area fixing the perspective to + * match the document's shape. + */ + scan_mode: 'none' | 'standard'; + + /** + * The URL to call when the task is completed or failed. If you want to receive + * events, you probably prefer to use `webhooks` instead. + */ + callback_url?: string; + + /** + * The name of the file + */ + name?: string; + } + + export interface Result { + generated_files: Array; + } + } + + export interface PendingScanTaskResponse { + /** + * The unique identifier for the task. + */ + id: string; + + /** + * The URL to which the task result will be sent upon completion or failure. + */ + callback_url: string | null; + + /** + * The creation date of the task in ISO format. + */ + created_at: string; + + /** + * The type of operation being performed by the task. + */ + operation: 'scan'; + + parameters: PendingScanTaskResponse.Parameters; + + result: unknown; + + /** + * The current status of the task. + */ + status: 'pending'; + + /** + * The last update date of the task in ISO format. + */ + updated_at: string; + } + + export namespace PendingScanTaskResponse { + export interface Parameters { + /** + * The effect to apply to the image + */ + effect: 'none' | 'grayscale' | 'scanner' | 'black-background'; + + /** + * The id of the file or task to operate on. + */ + input: string; + + /** + * Mode for detecting documents in the image. Available modes are: + * + * - **none**: No document detection is performed. + * - **standard**: Using a quick algorithm. Document is detected in the image, and + * the image is cropped to the detected document area fixing the perspective to + * match the document's shape. + */ + scan_mode: 'none' | 'standard'; + + /** + * The URL to call when the task is completed or failed. If you want to receive + * events, you probably prefer to use `webhooks` instead. + */ + callback_url?: string; + + /** + * The name of the file + */ + name?: string; + } + } + + export interface ProcessingScanTaskResponse { + /** + * The unique identifier for the task. + */ + id: string; + + /** + * The URL to which the task result will be sent upon completion or failure. + */ + callback_url: string | null; + + /** + * The creation date of the task in ISO format. + */ + created_at: string; + + /** + * The type of operation being performed by the task. + */ + operation: 'scan'; + + parameters: ProcessingScanTaskResponse.Parameters; + + result: unknown; + + /** + * The current status of the task. + */ + status: 'processing'; + + /** + * The last update date of the task in ISO format. + */ + updated_at: string; + } + + export namespace ProcessingScanTaskResponse { + export interface Parameters { + /** + * The effect to apply to the image + */ + effect: 'none' | 'grayscale' | 'scanner' | 'black-background'; + + /** + * The id of the file or task to operate on. + */ + input: string; + + /** + * Mode for detecting documents in the image. Available modes are: + * + * - **none**: No document detection is performed. + * - **standard**: Using a quick algorithm. Document is detected in the image, and + * the image is cropped to the detected document area fixing the perspective to + * match the document's shape. + */ + scan_mode: 'none' | 'standard'; + + /** + * The URL to call when the task is completed or failed. If you want to receive + * events, you probably prefer to use `webhooks` instead. + */ + callback_url?: string; + + /** + * The name of the file + */ + name?: string; + } + } + + export interface FailedScanTaskResponse { + /** + * The unique identifier for the task. + */ + id: string; + + /** + * The URL to which the task result will be sent upon completion or failure. + */ + callback_url: string | null; + + /** + * The creation date of the task in ISO format. + */ + created_at: string; + + /** + * The type of operation being performed by the task. + */ + operation: 'scan'; + + parameters: FailedScanTaskResponse.Parameters; + + result: FailedScanTaskResponse.Result; + + /** + * The current status of the task. + */ + status: 'failed'; + + /** + * The last update date of the task in ISO format. + */ + updated_at: string; + } + + export namespace FailedScanTaskResponse { + export interface Parameters { + /** + * The effect to apply to the image + */ + effect: 'none' | 'grayscale' | 'scanner' | 'black-background'; + + /** + * The id of the file or task to operate on. + */ + input: string; + + /** + * Mode for detecting documents in the image. Available modes are: + * + * - **none**: No document detection is performed. + * - **standard**: Using a quick algorithm. Document is detected in the image, and + * the image is cropped to the detected document area fixing the perspective to + * match the document's shape. + */ + scan_mode: 'none' | 'standard'; + + /** + * The URL to call when the task is completed or failed. If you want to receive + * events, you probably prefer to use `webhooks` instead. + */ + callback_url?: string; + + /** + * The name of the file + */ + name?: string; + } + + export interface Result { + details: { [key: string]: unknown }; + + error: string; + } + } +} + /** * Transform an image by warping it to a quadrilateral. */ @@ -1589,6 +1912,39 @@ export declare namespace ImageOperationExtractTextParams { } } +export interface ImageOperationScanParams { + /** + * The effect to apply to the image + */ + effect: 'none' | 'grayscale' | 'scanner' | 'black-background'; + + /** + * The id of the file or task to operate on. + */ + input: string; + + /** + * Mode for detecting documents in the image. Available modes are: + * + * - **none**: No document detection is performed. + * - **standard**: Using a quick algorithm. Document is detected in the image, and + * the image is cropped to the detected document area fixing the perspective to + * match the document's shape. + */ + scan_mode: 'none' | 'standard'; + + /** + * The URL to call when the task is completed or failed. If you want to receive + * events, you probably prefer to use `webhooks` instead. + */ + callback_url?: string; + + /** + * The name of the file + */ + name?: string; +} + export interface ImageOperationWarpParams { /** * The id of the file or task to operate on. @@ -1623,12 +1979,14 @@ export declare namespace ImageOperations { type ExtractTextRequest as ExtractTextRequest, type ExtractTextResponse as ExtractTextResponse, type ImageFromTaskResponse as ImageFromTaskResponse, + type ScanResponse as ScanResponse, type WarpRequest as WarpRequest, type WarpResponse as WarpResponse, type ImageOperationApplyEffectParams as ImageOperationApplyEffectParams, type ImageOperationConvertParams as ImageOperationConvertParams, type ImageOperationDetectDocumentsParams as ImageOperationDetectDocumentsParams, type ImageOperationExtractTextParams as ImageOperationExtractTextParams, + type ImageOperationScanParams as ImageOperationScanParams, type ImageOperationWarpParams as ImageOperationWarpParams, }; } diff --git a/src/resources/index.ts b/src/resources/index.ts index d5e18da..0fe535d 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -14,12 +14,14 @@ export { type ExtractTextRequest, type ExtractTextResponse, type ImageFromTaskResponse, + type ScanResponse, type WarpRequest, type WarpResponse, type ImageOperationApplyEffectParams, type ImageOperationConvertParams, type ImageOperationDetectDocumentsParams, type ImageOperationExtractTextParams, + type ImageOperationScanParams, type ImageOperationWarpParams, } from './image-operations'; export { diff --git a/src/version.ts b/src/version.ts index 66c10e6..9b7cfeb 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.1.0-alpha.5'; // x-release-please-version +export const VERSION = '0.1.0-alpha.6'; // x-release-please-version diff --git a/tests/api-resources/events.test.ts b/tests/api-resources/events.test.ts index dc19fa5..b795fb3 100644 --- a/tests/api-resources/events.test.ts +++ b/tests/api-resources/events.test.ts @@ -8,7 +8,7 @@ const client = new ScanDocuments({ }); describe('resource events', () => { - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('list', async () => { const responsePromise = client.events.list(); const rawResponse = await responsePromise.asResponse(); @@ -20,7 +20,7 @@ describe('resource events', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('list: request options and params are passed correctly', async () => { // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error await expect( diff --git a/tests/api-resources/files.test.ts b/tests/api-resources/files.test.ts index 2526df0..eab9ce8 100644 --- a/tests/api-resources/files.test.ts +++ b/tests/api-resources/files.test.ts @@ -8,7 +8,7 @@ const client = new ScanDocuments({ }); describe('resource files', () => { - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('retrieve', async () => { const responsePromise = client.files.retrieve('id'); const rawResponse = await responsePromise.asResponse(); @@ -20,7 +20,7 @@ describe('resource files', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('list', async () => { const responsePromise = client.files.list(); const rawResponse = await responsePromise.asResponse(); @@ -32,7 +32,7 @@ describe('resource files', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('list: request options and params are passed correctly', async () => { // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error await expect( @@ -40,7 +40,7 @@ describe('resource files', () => { ).rejects.toThrow(ScanDocuments.NotFoundError); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('delete', async () => { const responsePromise = client.files.delete('id'); const rawResponse = await responsePromise.asResponse(); @@ -52,7 +52,7 @@ describe('resource files', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('upload: only required params', async () => { const responsePromise = client.files.upload({ file: await toFile(Buffer.from('# my file contents'), 'README.md'), @@ -67,7 +67,7 @@ describe('resource files', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('upload: required and optional params', async () => { const response = await client.files.upload({ file: await toFile(Buffer.from('# my file contents'), 'README.md'), diff --git a/tests/api-resources/image-operations.test.ts b/tests/api-resources/image-operations.test.ts index 07957d7..2036b06 100644 --- a/tests/api-resources/image-operations.test.ts +++ b/tests/api-resources/image-operations.test.ts @@ -8,7 +8,7 @@ const client = new ScanDocuments({ }); describe('resource imageOperations', () => { - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('applyEffect: only required params', async () => { const responsePromise = client.imageOperations.applyEffect({ effect: 'grayscale', @@ -23,7 +23,7 @@ describe('resource imageOperations', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('applyEffect: required and optional params', async () => { const response = await client.imageOperations.applyEffect({ effect: 'grayscale', @@ -33,7 +33,7 @@ describe('resource imageOperations', () => { }); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('convert: only required params', async () => { const responsePromise = client.imageOperations.convert({ input: 'file_avyrvozb9302uwhq', @@ -48,7 +48,7 @@ describe('resource imageOperations', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('convert: required and optional params', async () => { const response = await client.imageOperations.convert({ input: 'file_avyrvozb9302uwhq', @@ -58,7 +58,7 @@ describe('resource imageOperations', () => { }); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('detectDocuments: only required params', async () => { const responsePromise = client.imageOperations.detectDocuments({ input: 'file_avyrvozb9302uwhq' }); const rawResponse = await responsePromise.asResponse(); @@ -70,7 +70,7 @@ describe('resource imageOperations', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('detectDocuments: required and optional params', async () => { const response = await client.imageOperations.detectDocuments({ input: 'file_avyrvozb9302uwhq', @@ -78,7 +78,7 @@ describe('resource imageOperations', () => { }); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('extractText: only required params', async () => { const responsePromise = client.imageOperations.extractText({ format: 'plain', @@ -93,7 +93,7 @@ describe('resource imageOperations', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('extractText: required and optional params', async () => { const response = await client.imageOperations.extractText({ format: 'plain', @@ -102,7 +102,34 @@ describe('resource imageOperations', () => { }); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled + test.skip('scan: only required params', async () => { + const responsePromise = client.imageOperations.scan({ + effect: 'none', + input: 'file_avyrvozb9302uwhq', + scan_mode: 'standard', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Prism tests are disabled + test.skip('scan: required and optional params', async () => { + const response = await client.imageOperations.scan({ + effect: 'none', + input: 'file_avyrvozb9302uwhq', + scan_mode: 'standard', + callback_url: 'https://example.com/callback', + name: 'Example Image', + }); + }); + + // Prism tests are disabled test.skip('warp: only required params', async () => { const responsePromise = client.imageOperations.warp({ input: 'file_avyrvozb9302uwhq', @@ -122,7 +149,7 @@ describe('resource imageOperations', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('warp: required and optional params', async () => { const response = await client.imageOperations.warp({ input: 'file_avyrvozb9302uwhq', diff --git a/tests/api-resources/pdf-operations.test.ts b/tests/api-resources/pdf-operations.test.ts index dc39d81..42a9f2f 100644 --- a/tests/api-resources/pdf-operations.test.ts +++ b/tests/api-resources/pdf-operations.test.ts @@ -8,7 +8,7 @@ const client = new ScanDocuments({ }); describe('resource pdfOperations', () => { - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('extractPages: only required params', async () => { const responsePromise = client.pdfOperations.extractPages({ input: 'file_avyrvozb9302uwhq', @@ -23,7 +23,7 @@ describe('resource pdfOperations', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('extractPages: required and optional params', async () => { const response = await client.pdfOperations.extractPages({ input: 'file_avyrvozb9302uwhq', @@ -33,7 +33,7 @@ describe('resource pdfOperations', () => { }); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('merge: only required params', async () => { const responsePromise = client.pdfOperations.merge({ input: ['file_avyrvozb9302uwhq'] }); const rawResponse = await responsePromise.asResponse(); @@ -45,7 +45,7 @@ describe('resource pdfOperations', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('merge: required and optional params', async () => { const response = await client.pdfOperations.merge({ input: ['file_avyrvozb9302uwhq'], @@ -54,7 +54,7 @@ describe('resource pdfOperations', () => { }); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('render: only required params', async () => { const responsePromise = client.pdfOperations.render({ input: 'file_avyrvozb9302uwhq' }); const rawResponse = await responsePromise.asResponse(); @@ -66,7 +66,7 @@ describe('resource pdfOperations', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('render: required and optional params', async () => { const response = await client.pdfOperations.render({ input: 'file_avyrvozb9302uwhq', @@ -77,7 +77,7 @@ describe('resource pdfOperations', () => { }); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('split: only required params', async () => { const responsePromise = client.pdfOperations.split({ input: 'file_avyrvozb9302uwhq' }); const rawResponse = await responsePromise.asResponse(); @@ -89,7 +89,7 @@ describe('resource pdfOperations', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('split: required and optional params', async () => { const response = await client.pdfOperations.split({ input: 'file_avyrvozb9302uwhq', diff --git a/tests/api-resources/tasks.test.ts b/tests/api-resources/tasks.test.ts index d935f79..ba551fa 100644 --- a/tests/api-resources/tasks.test.ts +++ b/tests/api-resources/tasks.test.ts @@ -8,7 +8,7 @@ const client = new ScanDocuments({ }); describe('resource tasks', () => { - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('retrieve', async () => { const responsePromise = client.tasks.retrieve('task_euyrvozb9302uwhq'); const rawResponse = await responsePromise.asResponse(); @@ -20,7 +20,7 @@ describe('resource tasks', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('list', async () => { const responsePromise = client.tasks.list(); const rawResponse = await responsePromise.asResponse(); @@ -32,7 +32,7 @@ describe('resource tasks', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // skipped: tests are disabled for the time being + // Prism tests are disabled test.skip('list: request options and params are passed correctly', async () => { // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error await expect( diff --git a/tests/index.test.ts b/tests/index.test.ts index 92a38ff..a75ea2b 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -26,13 +26,13 @@ describe('instantiate client', () => { apiKey: 'My API Key', }); - test('they are used in the request', () => { - const { req } = client.buildRequest({ path: '/foo', method: 'post' }); + test('they are used in the request', async () => { + const { req } = await client.buildRequest({ path: '/foo', method: 'post' }); expect(req.headers.get('x-my-default-header')).toEqual('2'); }); - test('can ignore `undefined` and leave the default', () => { - const { req } = client.buildRequest({ + test('can ignore `undefined` and leave the default', async () => { + const { req } = await client.buildRequest({ path: '/foo', method: 'post', headers: { 'X-My-Default-Header': undefined }, @@ -40,8 +40,8 @@ describe('instantiate client', () => { expect(req.headers.get('x-my-default-header')).toEqual('2'); }); - test('can be removed with `null`', () => { - const { req } = client.buildRequest({ + test('can be removed with `null`', async () => { + const { req } = await client.buildRequest({ path: '/foo', method: 'post', headers: { 'X-My-Default-Header': null }, @@ -354,7 +354,7 @@ describe('instantiate client', () => { }); describe('withOptions', () => { - test('creates a new client with overridden options', () => { + test('creates a new client with overridden options', async () => { const client = new ScanDocuments({ baseURL: 'http://localhost:5000/', maxRetries: 3, @@ -379,7 +379,7 @@ describe('instantiate client', () => { expect(newClient.constructor).toBe(client.constructor); }); - test('inherits options from the parent client', () => { + test('inherits options from the parent client', async () => { const client = new ScanDocuments({ baseURL: 'http://localhost:5000/', defaultHeaders: { 'X-Test-Header': 'test-value' }, @@ -394,7 +394,7 @@ describe('instantiate client', () => { // Test inherited options remain the same expect(newClient.buildURL('/foo', null)).toEqual('http://localhost:5001/foo?test-param=test-value'); - const { req } = newClient.buildRequest({ path: '/foo', method: 'get' }); + const { req } = await newClient.buildRequest({ path: '/foo', method: 'get' }); expect(req.headers.get('x-test-header')).toEqual('test-value'); }); @@ -448,8 +448,8 @@ describe('request building', () => { const client = new ScanDocuments({ apiKey: 'My API Key' }); describe('custom headers', () => { - test('handles undefined', () => { - const { req } = client.buildRequest({ + test('handles undefined', async () => { + const { req } = await client.buildRequest({ path: '/foo', method: 'post', body: { value: 'hello' }, @@ -484,8 +484,8 @@ describe('default encoder', () => { } } for (const jsonValue of [{}, [], { __proto__: null }, new Serializable(), new Collection(['item'])]) { - test(`serializes ${util.inspect(jsonValue)} as json`, () => { - const { req } = client.buildRequest({ + test(`serializes ${util.inspect(jsonValue)} as json`, async () => { + const { req } = await client.buildRequest({ path: '/foo', method: 'post', body: jsonValue, @@ -508,7 +508,7 @@ describe('default encoder', () => { asyncIterable, ]) { test(`converts ${util.inspect(streamValue)} to ReadableStream`, async () => { - const { req } = client.buildRequest({ + const { req } = await client.buildRequest({ path: '/foo', method: 'post', body: streamValue, @@ -521,7 +521,7 @@ describe('default encoder', () => { } test(`can set content-type for ReadableStream`, async () => { - const { req } = client.buildRequest({ + const { req } = await client.buildRequest({ path: '/foo', method: 'post', body: new Response('a\nb\nc\n').body, diff --git a/tests/path.test.ts b/tests/path.test.ts index 096bc58..15eb7d4 100644 --- a/tests/path.test.ts +++ b/tests/path.test.ts @@ -1,5 +1,6 @@ import { createPathTagFunction, encodeURIPath } from 'scan-documents/internal/utils/path'; import { inspect } from 'node:util'; +import { runInNewContext } from 'node:vm'; describe('path template tag function', () => { test('validates input', () => { @@ -32,9 +33,114 @@ describe('path template tag function', () => { return testParams.flatMap((e) => rest.map((r) => [e, ...r])); } - // we need to test how %2E is handled so we use a custom encoder that does no escaping + // We need to test how %2E is handled, so we use a custom encoder that does no escaping. const rawPath = createPathTagFunction((s) => s); + const emptyObject = {}; + const mathObject = Math; + const numberObject = new Number(); + const stringObject = new String(); + const basicClass = new (class {})(); + const classWithToString = new (class { + toString() { + return 'ok'; + } + })(); + + // Invalid values + expect(() => rawPath`/a/${null}/b`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Null is not a valid path parameter\n' + + '/a/null/b\n' + + ' ^^^^', + ); + expect(() => rawPath`/a/${undefined}/b`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Undefined is not a valid path parameter\n' + + '/a/undefined/b\n' + + ' ^^^^^^^^^', + ); + expect(() => rawPath`/a/${emptyObject}/b`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Object is not a valid path parameter\n' + + '/a/[object Object]/b\n' + + ' ^^^^^^^^^^^^^^^', + ); + expect(() => rawPath`?${mathObject}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Math is not a valid path parameter\n' + + '?[object Math]\n' + + ' ^^^^^^^^^^^^^', + ); + expect(() => rawPath`/${basicClass}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Object is not a valid path parameter\n' + + '/[object Object]\n' + + ' ^^^^^^^^^^^^^^', + ); + expect(() => rawPath`/../${''}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + + '/../\n' + + ' ^^', + ); + expect(() => rawPath`/../${{}}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + + 'Value of type Object is not a valid path parameter\n' + + '/../[object Object]\n' + + ' ^^ ^^^^^^^^^^^^^^', + ); + + // Valid values + expect(rawPath`/${0}`).toBe('/0'); + expect(rawPath`/${''}`).toBe('/'); + expect(rawPath`/${numberObject}`).toBe('/0'); + expect(rawPath`${stringObject}/`).toBe('/'); + expect(rawPath`/${classWithToString}`).toBe('/ok'); + + // We need to check what happens with cross-realm values, which we might get from + // Jest or other frames in a browser. + + const newRealm = runInNewContext('globalThis'); + expect(newRealm.Object).not.toBe(Object); + + const crossRealmObject = newRealm.Object(); + const crossRealmMathObject = newRealm.Math; + const crossRealmNumber = new newRealm.Number(); + const crossRealmString = new newRealm.String(); + const crossRealmClass = new (class extends newRealm.Object {})(); + const crossRealmClassWithToString = new (class extends newRealm.Object { + toString() { + return 'ok'; + } + })(); + + // Invalid cross-realm values + expect(() => rawPath`/a/${crossRealmObject}/b`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Object is not a valid path parameter\n' + + '/a/[object Object]/b\n' + + ' ^^^^^^^^^^^^^^^', + ); + expect(() => rawPath`?${crossRealmMathObject}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Math is not a valid path parameter\n' + + '?[object Math]\n' + + ' ^^^^^^^^^^^^^', + ); + expect(() => rawPath`/${crossRealmClass}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Object is not a valid path parameter\n' + + '/[object Object]\n' + + ' ^^^^^^^^^^^^^^^', + ); + + // Valid cross-realm values + expect(rawPath`/${crossRealmNumber}`).toBe('/0'); + expect(rawPath`${crossRealmString}/`).toBe('/'); + expect(rawPath`/${crossRealmClassWithToString}`).toBe('/ok'); + const results: { [pathParts: string]: { [params: string]: { valid: boolean; result?: string; error?: string }; @@ -85,6 +191,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' + '/path_params/%2E%2e/a\n' + ' ^^^^^^', }, @@ -92,6 +199,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E" can\'t be safely passed as a path parameter\n' + '/path_params/%2E/a\n' + ' ^^^', }, @@ -103,6 +211,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e%2E" can\'t be safely passed as a path parameter\n' + '/path_params/%2e%2E/\n' + ' ^^^^^^', }, @@ -110,6 +219,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e" can\'t be safely passed as a path parameter\n' + '/path_params/%2e/\n' + ' ^^^', }, @@ -121,6 +231,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E" can\'t be safely passed as a path parameter\n' + '/path_params/%2E\n' + ' ^^^', }, @@ -128,6 +239,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' + '/path_params/%2E%2e\n' + ' ^^^^^^', }, @@ -137,11 +249,17 @@ describe('path template tag function', () => { '["x"]': { valid: true, result: 'x/a' }, '["%2E"]': { valid: false, - error: 'Error: Path parameters result in path with invalid segments:\n%2E/a\n^^^', + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E" can\'t be safely passed as a path parameter\n%2E/a\n^^^', }, '["%2e%2E"]': { valid: false, - error: 'Error: Path parameters result in path with invalid segments:\n' + '%2e%2E/a\n' + '^^^^^^', + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e%2E" can\'t be safely passed as a path parameter\n' + + '%2e%2E/a\n' + + '^^^^^^', }, }, '["","/"]': { @@ -149,11 +267,18 @@ describe('path template tag function', () => { '[""]': { valid: true, result: '/' }, '["%2E%2e"]': { valid: false, - error: 'Error: Path parameters result in path with invalid segments:\n' + '%2E%2e/\n' + '^^^^^^', + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' + + '%2E%2e/\n' + + '^^^^^^', }, '["."]': { valid: false, - error: 'Error: Path parameters result in path with invalid segments:\n./\n^', + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + + './\n^', }, }, '["",""]': { @@ -161,11 +286,17 @@ describe('path template tag function', () => { '["x"]': { valid: true, result: 'x' }, '[".."]': { valid: false, - error: 'Error: Path parameters result in path with invalid segments:\n..\n^^', + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + + '..\n^^', }, '["."]': { valid: false, - error: 'Error: Path parameters result in path with invalid segments:\n.\n^', + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + + '.\n^', }, }, '["a"]': {}, @@ -185,6 +316,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2E" can\'t be safely passed as a path parameter\n' + '/path_params/%2E%2E?beta=true\n' + ' ^^^^^^', }, @@ -192,6 +324,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e%2E" can\'t be safely passed as a path parameter\n' + '/path_params/%2e%2E?beta=true\n' + ' ^^^^^^', }, @@ -203,6 +336,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + '/path_params/.?beta=true\n' + ' ^', }, @@ -210,6 +344,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e." can\'t be safely passed as a path parameter\n' + '/path_params/%2e.?beta=true\n' + ' ^^^^', }, @@ -221,6 +356,8 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + + 'Value "%2e" can\'t be safely passed as a path parameter\n' + '/path_params/./%2e/download\n' + ' ^ ^^^', }, @@ -228,6 +365,8 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' + + 'Value "%2e" can\'t be safely passed as a path parameter\n' + '/path_params/%2E%2e/%2e/download\n' + ' ^^^^^^ ^^^', }, @@ -243,6 +382,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E" can\'t be safely passed as a path parameter\n' + '/path_params/%2E/download\n' + ' ^^^', }, @@ -250,6 +390,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E." can\'t be safely passed as a path parameter\n' + '/path_params/%2E./download\n' + ' ^^^^', }, @@ -261,6 +402,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + '/path_params/./download\n' + ' ^', }, @@ -268,6 +410,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + '/path_params/../download\n' + ' ^^', }, @@ -279,6 +422,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + '/path_params/../download\n' + ' ^^', }, diff --git a/yarn.lock b/yarn.lock index 58c08d5..8311caf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -938,11 +938,11 @@ undici-types "~5.26.4" "@types/node@^20.17.6": - version "20.17.6" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.6.tgz#6e4073230c180d3579e8c60141f99efdf5df0081" - integrity sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ== + version "20.19.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.11.tgz#728cab53092bd5f143beed7fbba7ba99de3c16c4" + integrity sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow== dependencies: - undici-types "~6.19.2" + undici-types "~6.21.0" "@types/stack-utils@^2.0.0": version "2.0.3" @@ -3283,9 +3283,9 @@ ts-node@^10.5.0: v8-compile-cache-lib "^3.0.0" yn "3.1.1" -"tsc-multi@https://github.com/stainless-api/tsc-multi/releases/download/v1.1.8/tsc-multi.tgz": - version "1.1.8" - resolved "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.8/tsc-multi.tgz#f544b359b8f05e607771ffacc280e58201476b04" +"tsc-multi@https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz": + version "1.1.9" + resolved "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz#777f6f5d9e26bf0e94e5170990dd3a841d6707cd" dependencies: debug "^4.3.7" fast-glob "^3.3.2" @@ -3353,10 +3353,10 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== -undici-types@~6.19.2: - version "6.19.8" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" - integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== unicode-emoji-modifier-base@^1.0.0: version "1.0.0"