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
37 changes: 31 additions & 6 deletions src/client/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const ClientFactoryOptions = {
};

export class ClientFactory {
private readonly transportsByName: Map<string, TransportFactory>;
private readonly transportsByName: CaseInsensitiveMap<TransportFactory>;
private readonly agentCardResolver: AgentCardResolver;

constructor(public readonly options: ClientFactoryOptions = ClientFactoryOptions.default) {
Expand All @@ -84,8 +84,7 @@ export class ClientFactory {
}
this.transportsByName = transportsByName(options.transports);
for (const transport of options.preferredTransports ?? []) {
const factory = this.options.transports.find((t) => t.protocolName === transport);
if (!factory) {
if (!this.transportsByName.has(transport)) {
throw new Error(
`Unknown preferred transport: ${transport}, available transports: ${[...this.transportsByName.keys()].join()}`
);
Expand All @@ -100,7 +99,7 @@ export class ClientFactory {
async createFromAgentCard(agentCard: AgentCard): Promise<Client> {
const agentCardPreferred = agentCard.preferredTransport ?? JsonRpcTransportFactory.name;
const additionalInterfaces = agentCard.additionalInterfaces ?? [];
const urlsPerAgentTransports = new Map<string, string>([
const urlsPerAgentTransports = new CaseInsensitiveMap<string>([
[agentCardPreferred, agentCard.url],
...additionalInterfaces.map<[string, string]>((i) => [i.transport, i.url]),
]);
Expand Down Expand Up @@ -165,8 +164,8 @@ function mergeTransports(

function transportsByName(
transports: ReadonlyArray<TransportFactory> | undefined
): Map<string, TransportFactory> {
const result = new Map<string, TransportFactory>();
): CaseInsensitiveMap<TransportFactory> {
const result = new CaseInsensitiveMap<TransportFactory>();
if (!transports) {
return result;
}
Expand All @@ -189,3 +188,29 @@ function mergeArrays<T>(

return [...(a1 ?? []), ...(a2 ?? [])];
}

/**
* A Map that normalizes string keys to uppercase for case-insensitive lookups.
* This prevents errors from inconsistent casing in protocol names.
*/
class CaseInsensitiveMap<T> extends Map<string, T> {
private normalizeKey(key: string): string {
return key.toUpperCase();
}

override set(key: string, value: T): this {
return super.set(this.normalizeKey(key), value);
}

override get(key: string): T | undefined {
return super.get(this.normalizeKey(key));
}

override has(key: string): boolean {
return super.has(this.normalizeKey(key));
}

override delete(key: string): boolean {
return super.delete(this.normalizeKey(key));
}
}
66 changes: 65 additions & 1 deletion test/client/factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('ClientFactory', () => {
preferredTransports: ['UnknownTransport'],
};
expect(() => new ClientFactory(options)).to.throw(
'Unknown preferred transport: UnknownTransport, available transports: Transport1'
'Unknown preferred transport: UnknownTransport, available transports: TRANSPORT1'
);
});

Expand All @@ -71,6 +71,30 @@ describe('ClientFactory', () => {

expect(factory.options).to.equal(options);
});

it('should accept preferred transport with different case', () => {
const options: ClientFactoryOptions = {
transports: [mockTransportFactory1],
preferredTransports: ['transport1'], // lowercase, but Transport1 is registered
};

// Should not throw
const factory = new ClientFactory(options);

expect(factory.options).to.equal(options);
});

it('should detect duplicate transports with different case as duplicates', () => {
const transport1Lower = {
protocolName: 'transport1', // lowercase
create: vi.fn(),
};
const options: ClientFactoryOptions = {
transports: [mockTransportFactory1, transport1Lower], // Transport1 and transport1
};

expect(() => new ClientFactory(options)).to.throw('Duplicate protocol name: transport1');
});
});

describe('createClient', () => {
Expand Down Expand Up @@ -163,6 +187,46 @@ describe('ClientFactory', () => {
expect(client.config).to.equal(clientConfig);
});

it('should match transport with case-insensitive protocol name', async () => {
// Transport factory uses "Transport1" but agent card uses "transport1" (lowercase)
agentCard.preferredTransport = 'transport1';
const factory = new ClientFactory({ transports: [mockTransportFactory1] });

const client = await factory.createFromAgentCard(agentCard);

expect(client).to.be.instanceOf(Client);
expect(mockTransportFactory1.create).toHaveBeenCalledExactlyOnceWith(
'http://transport1.com',
agentCard
);
});

it('should match HTTP+JSON transport regardless of case', async () => {
const httpJsonFactory = {
protocolName: 'HTTP+JSON',
create: vi.fn().mockResolvedValue(mockTransport),
};
agentCard.preferredTransport = 'http+json'; // lowercase
const factory = new ClientFactory({ transports: [httpJsonFactory] });

await factory.createFromAgentCard(agentCard);

expect(httpJsonFactory.create).toHaveBeenCalledTimes(1);
});

it('should match JSONRPC transport regardless of case', async () => {
const jsonRpcFactory = {
protocolName: 'JSONRPC',
create: vi.fn().mockResolvedValue(mockTransport),
};
agentCard.preferredTransport = 'JsonRpc'; // mixed case
const factory = new ClientFactory({ transports: [jsonRpcFactory] });

await factory.createFromAgentCard(agentCard);

expect(jsonRpcFactory.create).toHaveBeenCalledTimes(1);
});

it('should use card resolver with default path', async () => {
const cardResolver = {
resolve: vi.fn().mockResolvedValue(agentCard),
Expand Down