Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ See the [TypeScript handbook](http://www.typescriptlang.org/docs/handbook/declar
- yarn: `yarn add --dev webmcp-types`
- pnpm: `pnpm add -D webmcp-types`

This package requires TypeScript 5.0 or newer.

### Configure

Since this package is outside DefinitelyTyped, the dependency won't be picked up automatically.
Expand Down Expand Up @@ -65,6 +67,13 @@ you may need the following in `webpack.config.js`:
"types": ["webmcp-types"]
```

### Run the type tests

- `npm install`
- `npm test`

The tests in `index.test-d.ts` are statically checked with [Vitest typecheck mode](https://vitest.dev/guide/testing-types) against `tsconfig.json`; they are never executed.

### Publish a new npm package version

(only for people who have npm publish access)
Expand Down
54 changes: 52 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,49 @@
export {};

type IsUnion<T, TWhole = T> = T extends TWhole ? [TWhole] extends [T] ? false : true : never;
type NonUnionTupleElements<TTuple extends readonly string[]> = {
[TIndex in keyof TTuple]: true extends IsUnion<TTuple[TIndex]> ? never : TTuple[TIndex];
}[number];
type Simplify<T> = { [TKey in keyof T]: T[TKey] } & {};

type JsonSchemaRequiredKeys<TSchema> = TSchema extends {
readonly required: infer TRequired extends readonly string[];
} ? number extends TRequired["length"]
? never
: true extends IsUnion<TRequired>
? never
: string extends TRequired[number]
? never
: NonUnionTupleElements<TRequired>
: never;

type InferJsonSchema<TSchema> = TSchema extends { readonly const: infer TValue } ? TValue
: TSchema extends { readonly enum: readonly (infer TValue)[] } ? TValue
: TSchema extends { readonly type: "string" } ? string
: TSchema extends { readonly type: "number" | "integer" } ? number
: TSchema extends { readonly type: "boolean" } ? boolean
: TSchema extends { readonly type: "null" } ? null
: TSchema extends { readonly type: "array" }
? TSchema extends { readonly items: infer TItems } ? InferJsonSchema<TItems>[] : unknown[]
: TSchema extends { readonly type: "object" } | { readonly properties: object }
? TSchema extends { readonly properties: infer TProperties extends object }
? keyof TProperties extends never ? Record<string, unknown> : Simplify<{
-readonly [K in keyof TProperties]?: InferJsonSchema<TProperties[K]>;
} & {
-readonly [K in keyof TProperties & JsonSchemaRequiredKeys<TSchema>]-?: InferJsonSchema<TProperties[K]>;
}>
: Record<string, unknown>
: unknown;

type InferToolInput<TSchema> = [InferJsonSchema<TSchema>] extends [null | undefined] ? Record<string, unknown>
: InferJsonSchema<TSchema> extends object ? InferJsonSchema<TSchema>
: Record<string, unknown>;

declare global {
/**
* The WebMCP API enables web apps to provide JavaScript-based tools to AI agents.
*/
declare namespace WebMCP {
namespace WebMCP {
/**
* Value that may be returned synchronously or via Promise.
*/
Expand All @@ -23,7 +65,7 @@ declare namespace WebMCP {
* @param options Options passed when executing the tool.
* @returns A promise that resolves with the tool's output.
*/
type ToolExecuteCallback<T extends Record<string, unknown> = Record<string, unknown>> = (inputObject: T, options: ToolExecuteCallbackOptions) => MaybePromise<unknown>;
type ToolExecuteCallback<T extends object = Record<string, unknown>> = (inputObject: T, options: ToolExecuteCallbackOptions) => MaybePromise<unknown>;

/**
* Metadata about a tool's behavior.
Expand Down Expand Up @@ -71,6 +113,12 @@ declare namespace WebMCP {
annotations?: ToolAnnotations;
}

/** A tool whose execute input is inferred from its input schema. */
type ModelContextToolFromSchema<TInputSchema extends object> = Omit<ModelContextTool, "inputSchema" | "execute"> & {
inputSchema: TInputSchema;
execute: ToolExecuteCallback<InferToolInput<TInputSchema>>;
};

/**
* Options for registering a tool.
*/
Expand Down Expand Up @@ -143,6 +191,7 @@ declare namespace WebMCP {
* @param tool The tool definition.
* @param options Registration options.
*/
registerTool<const TInputSchema extends object>(tool: ModelContextToolFromSchema<TInputSchema>, options?: ModelContextRegisterToolOptions): Promise<void>;
registerTool(tool: ModelContextTool, options?: ModelContextRegisterToolOptions): Promise<void>;
/**
* Returns a list of registered tools exposed to this document.
Expand All @@ -168,3 +217,4 @@ interface Document {
*/
readonly modelContext?: WebMCP.ModelContext;
}
}
141 changes: 141 additions & 0 deletions index.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { expectTypeOf, test } from 'vitest';

test('keeps the WebMCP declarations ambient', () => {
expectTypeOf<Document['modelContext']>().toEqualTypeOf<WebMCP.ModelContext | undefined>();
});

test('infers an inline object schema', () => {
void document.modelContext?.registerTool({
name: 'search',
description: 'Searches the page.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
limit: { type: 'integer' },
tags: { type: 'array', items: { type: 'string' } },
filters: {
type: 'object',
properties: { exact: { type: 'boolean' } },
required: ['exact'],
},
mode: { enum: ['fast', 'full'] },
version: { const: 1 },
nothing: { type: 'null' },
},
required: ['query', 'tags'],
},
execute: (input, options) => {
expectTypeOf(input).toEqualTypeOf<{
query: string;
limit?: number;
tags: string[];
filters?: { exact: boolean };
mode?: 'fast' | 'full';
version?: 1;
nothing?: null;
}>();
expectTypeOf(options.signal).toEqualTypeOf<AbortSignal>();
},
});
});

test('infers a top-level array schema', () => {
void document.modelContext?.registerTool({
name: 'sum',
description: 'Sums an array.',
inputSchema: { type: 'array', items: { type: 'number' } },
execute: (input) => expectTypeOf(input).toEqualTypeOf<number[]>(),
});
});

interface InterfaceProperties {
query: { type: 'string' };
}

interface RuntimeSchema {
properties: InterfaceProperties;
required: readonly ['query'] | readonly [];
}

declare const runtimeSchema: RuntimeSchema;

test('leaves fields optional when runtime required tuples differ', () => {
void document.modelContext?.registerTool({
name: 'runtime-required',
description: 'Does not merge alternative required tuples.',
inputSchema: runtimeSchema,
execute: (input) => expectTypeOf(input).toEqualTypeOf<{ query?: string }>(),
});
});

declare const runtimeRequiredKey: 'query' | 'limit';

test('leaves fields optional when a required key is selected at runtime', () => {
void document.modelContext?.registerTool({
name: 'runtime-required-key',
description: 'Does not require every possible runtime key.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
limit: { type: 'integer' },
},
required: [runtimeRequiredKey],
},
execute: (input) => expectTypeOf(input).toEqualTypeOf<{ query?: string; limit?: number }>(),
});
});

declare const widenedSchema: object;

test('keeps the existing fallback for widened schemas and prebuilt tools', () => {
const tool = {
name: 'prebuilt',
description: 'Keeps the existing ModelContextTool contract.',
inputSchema: widenedSchema,
execute: (input) => expectTypeOf(input).toEqualTypeOf<Record<string, unknown>>(),
} satisfies WebMCP.ModelContextTool;

void document.modelContext?.registerTool(tool);
});

type SearchSchema = {
type: 'object';
properties: { query: { type: 'string' } };
required: ['query'];
};

test('exposes a named inferred tool type', () => {
expectTypeOf<WebMCP.ModelContextToolFromSchema<SearchSchema>['execute']>()
.parameter(0)
.toEqualTypeOf<{ query: string }>();
});

test('uses an object fallback for a null schema', () => {
expectTypeOf<WebMCP.ModelContextToolFromSchema<{ type: 'null' }>['execute']>()
.parameter(0)
.toEqualTypeOf<Record<string, unknown>>();
});

test('accepts a tool without an input schema', () => {
void document.modelContext?.registerTool({
name: 'schema-less',
description: 'Does not define input parameters.',
execute: (input) => expectTypeOf(input).toEqualTypeOf<Record<string, unknown>>(),
});
});

test('rejects a callback that disagrees with its schema', () => {
void document.modelContext?.registerTool({
name: 'mismatch',
description: 'Must match its schema.',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
// @ts-expect-error query is a string in inputSchema.
execute: (input: { query: number }) => input.query,
});
});
Loading