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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/httpserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import * as http from "node:http";

export function runHTTPServer(server: McpServer) {
export function runHTTPServer(server: McpServer): http.Server {
const app = express();
app.use(express.json());

Expand Down Expand Up @@ -81,5 +82,5 @@ export function runHTTPServer(server: McpServer) {
// Handle DELETE requests for session termination
app.delete('/mcp', handleSessionRequest);

app.listen(3000);
return app.listen(3000);
}
7 changes: 4 additions & 3 deletions src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {evalTS} from "./ts.js";
import { OpenAPIV3_1 } from "openapi-types";
import { ZodRawShape } from "zod";
import { Configuration } from "./command-line.js";
import http from "node:http";

function getParameters(jsonSchema: OpenAPIV3_1.SchemaObject): ZodRawShape {
const params: ZodRawShape = {}
Expand Down Expand Up @@ -107,7 +108,7 @@ function registerTool(server: McpServer, apikey: string, baseURL: string, decisi
}
}

export async function createMcpServer(name: string, configuration: Configuration): Promise<{ server: McpServer, transport?: StdioServerTransport }> {
export async function createMcpServer(name: string, configuration: Configuration): Promise<{ server: McpServer, transport?: StdioServerTransport, httpServer?: http.Server }> {
const version = configuration.version;
const server = new McpServer({
name: name,
Expand All @@ -131,8 +132,8 @@ export async function createMcpServer(name: string, configuration: Configuration

if (configuration.isHttpTransport()) {
debug("IBM Decision Intelligence MCP Server version", version, "running on http");
runHTTPServer(server);
return { server }
const httpServer = runHTTPServer(server);
return { server, httpServer }
}

const transport = configuration.transport!;
Expand Down
97 changes: 97 additions & 0 deletions tests/http-transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { Configuration } from "../src/command-line.js";
import { DecisionRuntime } from "../src/decision-runtime.js";
import { createMcpServer } from "../src/mcp-server.js";
import { Server } from "http";
import { TEST_CONFIG, TEST_INPUT, TEST_EXPECTATIONS, setupNockMocks, validateToolListing, validateToolExecution } from "./test-utils.js";

describe('HTTP Transport', () => {
const port = 3000; // Use the default port from runHTTPServer
const httpServerUrl = `http://localhost:${port}/mcp`;

beforeAll(() => {
setupNockMocks();
});

test('should properly list and execute tool when configured with HTTP transport', async () => {
// Create a custom configuration for HTTP transport
const configuration = new Configuration('validkey123', DecisionRuntime.DI, undefined, TEST_CONFIG.url, '1.2.3', true);

let server: McpServer | undefined;
let httpServer: Server | undefined;
let client: Client | undefined;

let clientTransport: StreamableHTTPClientTransport | undefined;

try {
// Create MCP server with HTTP transport - this will return the HTTP server
const result = await createMcpServer('test-server', configuration);
server = result.server;
httpServer = result.httpServer;

if (!httpServer) {
throw new Error('HTTP server not returned from createMcpServer');
}

// Create client with HTTP transport
client = new Client(
{
name: "http-client-test",
version: "1.0.0",
},
{
capabilities: {},
}
);

// Connect client to server via HTTP
clientTransport = new StreamableHTTPClientTransport(new URL(httpServerUrl));

await client.connect(clientTransport);

// Test tool listing
const toolList = await client.listTools();
validateToolListing(toolList.tools);

// Test tool execution
try {
const response = await client.callTool({
name: TEST_EXPECTATIONS.toolName,
arguments: TEST_INPUT
});
validateToolExecution(response);
} catch (error) {
console.error('Tool call failed:', error);
throw error;
}
} finally {
// Clean up resources in reverse order of creation
if (client) {
await client.close();
}

if (clientTransport) {
try {
await clientTransport.close();
} catch (e) {
console.error("Error closing client transport:", e);
}
}

if (server) {
try {
await server.close();
} catch (e) {
console.error("Error closing server:", e);
}
}

if (httpServer) {
httpServer.closeAllConnections();
httpServer.close();
}
}
});
});
104 changes: 12 additions & 92 deletions tests/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,18 @@ import {JSONRPCMessage, MessageExtraInfo} from "@modelcontextprotocol/sdk/types.
import {Client} from "@modelcontextprotocol/sdk/client/index.js";
import {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js";
import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js";
import type {Transport, TransportSendOptions} from '@modelcontextprotocol/sdk/shared/transport.js';
import type {Transport} from '@modelcontextprotocol/sdk/shared/transport.js';
import {Configuration} from "../src/command-line.js";
import {DecisionRuntime} from "../src/decision-runtime.js";
import {createMcpServer} from "../src/mcp-server.js";
import nock from "nock";
import {PassThrough, Readable, Writable} from 'stream';
import { TEST_CONFIG, TEST_INPUT, TEST_EXPECTATIONS, setupNockMocks, validateToolListing, validateToolExecution } from "./test-utils.js";

describe('Mcp Server', () => {

const protocol = 'https:';
const hostname = 'example.com';
const url = `${protocol}//${hostname}`;

const decisionServiceId = 'test/loan_approval/loanApprovalDecisionService/3-2025-06-18T13:00:39.447Z';
const operationId = 'approval';
const uri = '/selectors/lastDeployedDecisionService/deploymentSpaces/development/operations/' + encodeURIComponent(operationId) + '/execute?decisionServiceId=' + encodeURIComponent(decisionServiceId);
const output = {
"insurance": {
"rate": 2.5,
"required": true
},
"approval": {
"approved": true,
"message": "Loan approved based on income and credit score"
}
};
nock(url)
.get('/deploymentSpaces/development/metadata?names=decisionServiceId')
.reply(200, [{
'decisionServiceId': {
'name': 'decisionServiceId',
'kind': 'PLAIN',
'readOnly': true,
'value': decisionServiceId
}
}])
.get(`/selectors/lastDeployedDecisionService/deploymentSpaces/development/openapi?decisionServiceId=${decisionServiceId}&outputFormat=JSON/openapi`)
.replyWithFile(200, 'tests/loanvalidation-openapi.json')
.post(uri)
.reply(200, output);
beforeAll(() => {
setupNockMocks();
});

class StreamClientTransport implements Transport {
public onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
Expand Down Expand Up @@ -84,8 +56,7 @@ describe('Mcp Server', () => {
}

async send(
message: JSONRPCMessage,
_options?: TransportSendOptions
message: JSONRPCMessage
): Promise<void> {
const json = JSON.stringify(message) + "\n";
return new Promise<void>((resolve) => {
Expand All @@ -103,7 +74,7 @@ describe('Mcp Server', () => {
const fakeStdout = new PassThrough();
const transport = new StdioServerTransport(fakeStdin, fakeStdout);
const clientTransport = new StreamClientTransport(fakeStdout, fakeStdin);
const configuration = new Configuration('validkey123', DecisionRuntime.DI, transport, url, '1.2.3', true);
const configuration = new Configuration('validkey123', DecisionRuntime.DI, transport, TEST_CONFIG.url, '1.2.3', true);
let server: McpServer | undefined;
let client: Client | undefined;
try {
Expand All @@ -120,65 +91,14 @@ describe('Mcp Server', () => {
});
await client.connect(clientTransport);
const toolList = await client.listTools();
expect(toolList).toHaveProperty('tools');
const tools = toolList.tools;
expect(Array.isArray(tools)).toBe(true);
expect(tools).toHaveLength(1);

const loanApprovalTool = tools[0];
const toolName = 'Loan_Approval_approval';
expect(loanApprovalTool).toEqual(
expect.objectContaining({
name: toolName,
title: 'approval',
description: 'Execute approval'
})
);

expect(loanApprovalTool).toHaveProperty('inputSchema');
expect(typeof loanApprovalTool.inputSchema).toBe('object');

const input = {
loan: {
amount: 1000,
loanToValue: 1.5,
numberOfMonthlyPayments: 1000,
startDate: "2025-06-17T14:40:26Z"
},
borrower: {
SSN: {
areaNumber: "123",
groupCode: "45",
serialNumber: "6789"
},
birthDate: "1990-01-01T00:00:00Z",
creditScore: 750,
firstName: "Alice",
lastName: "Doe",
latestBankruptcy: {
chapter: 11,
date: "2010-01-01T00:00:00Z",
reason: "Medical debt"
},
yearlyIncome: 85000,
zipCode: "12345"
},
currentTime: new Date().toISOString()
};
validateToolListing(toolList.tools);

try {
const response = await client.callTool({
name: toolName,
arguments: input
}, );
expect(response).toBeDefined();
expect(response.isError).toBe(undefined);
const content = response.content as Array<{type: string, text: string}>;
expect(content).toBeDefined();
expect(Array.isArray(content)).toBe(true);
expect(content).toHaveLength(1);
const actualContent = content[0];
expect(actualContent.text).toEqual(JSON.stringify(output));
name: TEST_EXPECTATIONS.toolName,
arguments: TEST_INPUT
});
validateToolExecution(response);
} catch (error) {
console.error('Tool call failed:', error);
throw error;
Expand Down
108 changes: 108 additions & 0 deletions tests/test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import nock from "nock";

// Shared test data
export const TEST_CONFIG = {
protocol: 'https:',
hostname: 'example.com',
url: 'https://example.com',
decisionServiceId: 'test/loan_approval/loanApprovalDecisionService/3-2025-06-18T13:00:39.447Z',
operationId: 'approval',
output: {
"insurance": {
"rate": 2.5,
"required": true
},
"approval": {
"approved": true,
"message": "Loan approved based on income and credit score"
}
}
};

// Shared test input data
export const TEST_INPUT = {
loan: {
amount: 1000,
loanToValue: 1.5,
numberOfMonthlyPayments: 1000,
startDate: "2025-06-17T14:40:26Z"
},
borrower: {
SSN: {
areaNumber: "123",
groupCode: "45",
serialNumber: "6789"
},
birthDate: "1990-01-01T00:00:00Z",
creditScore: 750,
firstName: "Alice",
lastName: "Doe",
latestBankruptcy: {
chapter: 11,
date: "2010-01-01T00:00:00Z",
reason: "Medical debt"
},
yearlyIncome: 85000,
zipCode: "12345"
},
currentTime: new Date().toISOString()
};

// Shared test expectations
export const TEST_EXPECTATIONS = {
toolName: 'Loan_Approval_approval',
expectedTool: {
name: 'Loan_Approval_approval',
title: 'approval',
description: 'Execute approval'
}
};

// Setup nock mocks for testing
export function setupNockMocks(): void {
const { url, decisionServiceId, operationId, output } = TEST_CONFIG;
const uri = '/selectors/lastDeployedDecisionService/deploymentSpaces/development/operations/' +
encodeURIComponent(operationId) + '/execute?decisionServiceId=' +
encodeURIComponent(decisionServiceId);

nock(url)
.get('/deploymentSpaces/development/metadata?names=decisionServiceId')
.reply(200, [{
'decisionServiceId': {
'name': 'decisionServiceId',
'kind': 'PLAIN',
'readOnly': true,
'value': decisionServiceId
}
}])
.get(`/selectors/lastDeployedDecisionService/deploymentSpaces/development/openapi?decisionServiceId=${decisionServiceId}&outputFormat=JSON/openapi`)
.replyWithFile(200, 'tests/loanvalidation-openapi.json')
.post(uri)
.reply(200, output);
}

// Helper function to validate tool listing
export function validateToolListing(tools: any[]): void {
expect(Array.isArray(tools)).toBe(true);
expect(tools).toHaveLength(1);

const loanApprovalTool = tools[0];
expect(loanApprovalTool).toEqual(
expect.objectContaining(TEST_EXPECTATIONS.expectedTool)
);

expect(loanApprovalTool).toHaveProperty('inputSchema');
expect(typeof loanApprovalTool.inputSchema).toBe('object');
}

// Helper function to validate tool execution
export function validateToolExecution(response: any): void {
expect(response).toBeDefined();
expect(response.isError).toBe(undefined);
const content = response.content as Array<{type: string, text: string}>;
expect(content).toBeDefined();
expect(Array.isArray(content)).toBe(true);
expect(content).toHaveLength(1);
const actualContent = content[0];
expect(actualContent.text).toEqual(JSON.stringify(TEST_CONFIG.output));
}