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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/interface/src/http/IHttpLlmApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ export interface IHttpLlmApplication {

/** Configuration used for composition. */
config: IHttpLlmApplication.IConfig;

/**
* Version of the API, taken from the OpenAPI document's `info.version`.
*
* `undefined` when the source document carries no version info.
*/
version?: string | undefined;
}
export namespace IHttpLlmApplication {
/** Configuration for HTTP LLM application composition. */
Expand Down
11 changes: 10 additions & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

- **Tools**: every class method, with `inputSchema`, `outputSchema`, and `structuredContent` reflected from the types
- **Instructions**: the reflected class/interface JSDoc, shipped through the handshake
- **OpenAPI**: `HttpLlm.controller()` documents serve the same way — every operation becomes a tool calling the real endpoint, and `info.version` becomes the server version
- **Zero extra dependencies**: nothing at runtime beyond what `typia` already installs, plus the MCP SDK as a peer

## Setup
Expand All @@ -27,7 +28,7 @@ npx typia setup

## Usage

`createMcpServer(controller, version?)` — pass a `typia.llm.controller`, connect a transport. Every method of the class becomes an MCP tool; the controller's `name` is the server name and its class JSDoc becomes the handshake instructions.
`createMcpServer(controller, options?)` — pass a `typia.llm.controller` (or an `HttpLlm.controller` over an OpenAPI document), connect a transport. Every method of the class becomes an MCP tool; the controller's `name` is the server name and its class JSDoc becomes the handshake instructions. The handshake version is the OpenAPI `info.version` for HTTP controllers, `"1.0.0"` otherwise.

```typescript
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
Expand All @@ -48,6 +49,14 @@ const server = createMcpServer(
await server.connect(new StdioServerTransport());
```

Every typed tool result ships once, as `structuredContent`. The MCP spec also recommends a duplicate serialized-JSON text block for clients that ignore `outputSchema` — but that doubles the payload, and a size-capped client counts both copies. So the fallback is opt-in:

```typescript
const server = createMcpServer(controller, { textFallback: true });
```

Results with no structured representation (`void` methods, errors) always keep their text content.

## Validation feedback

If the LLM provides invalid arguments, the tool returns the input annotated with `// ❌` markers so the model can self-correct:
Expand Down
49 changes: 42 additions & 7 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ILlmController } from "@typia/interface";
import { IHttpLlmController, ILlmController } from "@typia/interface";

import { McpControllerRegistrar } from "./internal/McpControllerRegistrar";

/** Options of {@link createMcpServer}. */
export interface IMcpServerOptions {
/**
* Whether to add a serialized JSON text block next to `structuredContent` in
* every tool result.
*
* The MCP spec recommends the duplicate text copy as a fallback for clients
* that ignore `outputSchema`. But it doubles the payload, and a client that
* caps tool-result size counts both copies.
*
* So structured results ship once by default; opt in for legacy clients.
*
* A result with no structured representation (a `void` method, a validation
* failure, a runtime error) always keeps its text content.
*
* @default false
*/
textFallback?: boolean | undefined;
}

/**
* Create an MCP server over a single typia controller.
*
Expand All @@ -12,6 +32,10 @@ import { McpControllerRegistrar } from "./internal/McpControllerRegistrar";
* The controller's `name` becomes the server name, and the class JSDoc
* (`application.description`) becomes the MCP handshake instructions.
*
* An {@link IHttpLlmController} from `HttpLlm.controller()` works the same way:
* every OpenAPI operation becomes an MCP tool that calls the actual endpoint,
* and the document's `info.version` becomes the handshake version.
*
* Every tool call is validated by typia. If the LLM provides invalid arguments,
* it receives an {@link IValidation.IFailure} formatted by
* {@link LlmJson.stringify} so it can correct them automatically — the exact
Expand Down Expand Up @@ -41,23 +65,34 @@ import { McpControllerRegistrar } from "./internal/McpControllerRegistrar";
* ```
*
* @template Class Executor class type of the controller
* @param controller Controller from `typia.llm.controller<Class>()`
* @param version Server version for the MCP handshake
* @param controller Controller from `typia.llm.controller<Class>()` or
* `HttpLlm.controller()`
* @param options Optional behaviors of the server ({@link IMcpServerOptions})
* @returns McpServer ready to connect to a transport
*/
export function createMcpServer<Class extends object = any>(
controller: ILlmController<Class>,
version: string = "1.0.0",
controller: ILlmController<Class> | IHttpLlmController,
options?: IMcpServerOptions,
): McpServer {
const instructions: string | undefined =
controller.application.description?.trim() || undefined;
controller.protocol === "http"
? undefined
: controller.application.description?.trim() || undefined;
const version: string =
(controller.protocol === "http"
? controller.application.version
: undefined) ?? "1.0.0";
const server: McpServer = new McpServer(
{ name: controller.name, version },
{
capabilities: { tools: {} },
...(instructions !== undefined ? { instructions } : {}),
},
);
McpControllerRegistrar.register(server.server, controller);
McpControllerRegistrar.register(
server.server,
controller,
options?.textFallback === true,
);
return server;
}
77 changes: 59 additions & 18 deletions packages/mcp/src/internal/McpControllerRegistrar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,33 @@ import {
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import {
IHttpLlmController,
IHttpLlmFunction,
IHttpResponse,
ILlmController,
ILlmFunction,
ILlmSchema,
IValidation,
} from "@typia/interface";
import { LlmJson } from "@typia/utils";
import { HttpLlm, LlmJson } from "@typia/utils";

export namespace McpControllerRegistrar {
export const register = (
server: Server,
controller: ILlmController,
controller: ILlmController | IHttpLlmController,
textFallback: boolean,
): void => {
// Build tool registry from the controller's functions
const registry: Map<string, IToolEntry> = new Map();
const execute: Record<string, unknown> = controller.execute;
for (const func of controller.application.functions) {
if (registry.has(func.name)) {
throw new Error(
`Duplicate function name "${func.name}" in controller "${controller.name}"`,
);
}
const method: unknown = execute[func.name];
if (typeof method !== "function") {
throw new Error(
`Method "${func.name}" not found on controller "${controller.name}"`,
);
}
registry.set(func.name, {
function: func,
execute: async (args: unknown) => method.call(execute, args),
execute: composeExecute(controller, func),
});
}

Expand Down Expand Up @@ -68,13 +65,48 @@ export namespace McpControllerRegistrar {
`Unknown tool: ${request.params.name}`,
);
}
return handleToolCall(entry, request.params.arguments);
return handleToolCall(entry, request.params.arguments, textFallback);
});
};

const composeExecute = (
controller: ILlmController | IHttpLlmController,
func: ILlmFunction,
): ((args: unknown) => Promise<unknown>) => {
if (controller.protocol === "http") {
const httpFunction: IHttpLlmFunction = func as IHttpLlmFunction;
return async (args: unknown) => {
if (controller.execute !== undefined) {
const response: IHttpResponse = await controller.execute({
connection: controller.connection,
application: controller.application,
function: httpFunction,
arguments: args as object,
});
return response.body;
}
return HttpLlm.execute({
application: controller.application,
function: httpFunction,
connection: controller.connection,
input: args as object,
});
};
}
const execute: Record<string, unknown> = controller.execute;
const method: unknown = execute[func.name];
if (typeof method !== "function") {
throw new Error(
`Method "${func.name}" not found on controller "${controller.name}"`,
);
}
return async (args: unknown) => method.call(execute, args);
};

const handleToolCall = async (
entry: IToolEntry,
args: unknown,
textFallback: boolean,
): Promise<CallToolResult> => {
// Validate an empty object when a client omits `arguments` — the MCP spec
// allows the omission for zero-parameter tools, and validating `{}` also
Expand All @@ -97,15 +129,24 @@ export namespace McpControllerRegistrar {
if (result === undefined) {
return { content: [{ type: "text" as const, text: "Success" }] };
}
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
// When the reflected return type schema exists, ship the result as
// structured output too; the text block above stays as the
// spec-recommended fallback for clients that ignore `outputSchema`.
...(entry.function.output !== undefined &&
// When the reflected return type schema exists, ship the result as
// structured output, once. The spec's duplicate text fallback for
// clients that ignore `outputSchema` doubles the payload, so it is
// opt-in through `textFallback: true`.
//
// A result that cannot ship as `structuredContent` keeps its text
// block regardless — dropping it would leave the call empty.
const structured: boolean =
entry.function.output !== undefined &&
typeof result === "object" &&
result !== null &&
!Array.isArray(result)
!Array.isArray(result);
return {
content:
structured && !textFallback
? []
: [{ type: "text" as const, text: JSON.stringify(result) }],
...(structured
? { structuredContent: result as Record<string, unknown> }
: {}),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,13 @@ export namespace HttpLlmApplicationComposer {
})
.filter((v): v is IHttpLlmFunction => v !== null);

const version: string | undefined =
props.migrate.document().info?.version || undefined;
const app: IHttpLlmApplication = {
config,
functions,
errors,
...(version !== undefined ? { version } : {}),
};
shorten(app, props.config?.maxLength ?? 64);
return app;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,9 @@ export const test_mcp_class_controller_coercion = async (): Promise<void> => {
"coerced call is not an error",
() => result.isError !== true,
);
TestValidator.predicate("coerced call returns the sum", () =>
result.content.some(
(x) =>
x.type === "text" &&
(JSON.parse(x.text) as { value: number }).value === 7,
),
TestValidator.equals(
"coerced call returns the sum",
result.structuredContent,
{ value: 7 },
);
};
14 changes: 7 additions & 7 deletions tests/test-mcp/src/features/test_mcp_class_controller_execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { Calculator } from "../structures/Calculator";
* Verifies each class method executes as a tool and returns its result.
*
* Locks the happy-path dispatch of the tools/call handler: the method named in
* the request runs on the controller instance and its object return is
* serialized back as the tool result. A regression would misroute the call or
* drop the computed value.
* the request runs on the controller instance and its object return ships back
* as `structuredContent`. A regression would misroute the call or drop the
* computed value.
*
* 1. Serve a `Calculator` controller and grab its tools/call handler.
* 2. Call add, subtract, multiply, and divide with concrete operands.
Expand All @@ -39,7 +39,7 @@ export const test_mcp_class_controller_execute = async (): Promise<void> => {
);
TestValidator.equals(
"add(10, 5) should return 15",
JSON.parse((addResult.content[0] as { text: string }).text),
addResult.structuredContent,
{ value: 15 },
);

Expand All @@ -52,7 +52,7 @@ export const test_mcp_class_controller_execute = async (): Promise<void> => {
);
TestValidator.equals(
"subtract(10, 3) should return 7",
JSON.parse((subtractResult.content[0] as { text: string }).text),
subtractResult.structuredContent,
{ value: 7 },
);

Expand All @@ -65,7 +65,7 @@ export const test_mcp_class_controller_execute = async (): Promise<void> => {
);
TestValidator.equals(
"multiply(4, 7) should return 28",
JSON.parse((multiplyResult.content[0] as { text: string }).text),
multiplyResult.structuredContent,
{ value: 28 },
);

Expand All @@ -78,7 +78,7 @@ export const test_mcp_class_controller_execute = async (): Promise<void> => {
);
TestValidator.equals(
"divide(20, 4) should return 5",
JSON.parse((divideResult.content[0] as { text: string }).text),
divideResult.structuredContent,
{ value: 5 },
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export const test_mcp_create_server_lazy_controller =
return { value: 42 };
}),
),
"0.16.8",
);

const raw: Server = server.server;
Expand All @@ -61,7 +60,7 @@ export const test_mcp_create_server_lazy_controller =
TestValidator.equals("first call builds the state once", built, 1);
TestValidator.equals(
"call returns the reflected result",
JSON.parse((result.content[0] as { text: string }).text),
result.structuredContent,
{ answer: "depth=42" },
);
};
28 changes: 28 additions & 0 deletions tests/test-mcp/src/features/test_mcp_create_server_version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { TestValidator } from "@nestia/e2e";
import { createMcpServer } from "@typia/mcp";
import typia from "typia";

import { Greeter } from "../structures/Greeter";

/**
* Verifies a class controller announces the fixed `"1.0.0"` handshake version.
*
* `IMcpServerOptions` intentionally carries no version knob: an HTTP controller
* inherits the OpenAPI `info.version`, and a class controller has no version
* source, so the handshake pins the `"1.0.0"` default. A regression would
* announce `undefined` and break the SDK's `Implementation` contract.
*
* 1. Create a server over a class controller.
* 2. Assert the underlying SDK server's `serverInfo.version` is `"1.0.0"`.
*/
export const test_mcp_create_server_version = async (): Promise<void> => {
const server: McpServer = createMcpServer(
typia.llm.controller<Greeter>("greeter", new Greeter()),
);
TestValidator.equals(
"class controller should announce the 1.0.0 default",
((server.server as any)._serverInfo as { version: string }).version,
"1.0.0",
);
};
Loading
Loading