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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@decodo/mcp-server",
"version": "1.2.3",
"version": "1.2.4",
"description": "Decodo MCP Server",
"bin": {
"decodo-mcp": "./build/index.js"
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
"url": "https://github.com/Decodo/mcp-server",
"source": "github"
},
"version": "1.2.3",
"version": "1.2.4",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "@decodo/mcp-server",
"version": "1.2.3",
"version": "1.2.4",
"transport": {
"type": "stdio"
}
Expand Down
85 changes: 85 additions & 0 deletions src/auth/__tests__/credential.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { AUTH_TYPE } from '../constants';
import { credentialFromAuthHeader, credentialFromValue } from '../credential';
import { detectCredentialType } from '../detect-credential-type';

const base64 = (value: string) => Buffer.from(value).toString('base64');

describe('detectCredentialType', () => {
it('treats base64 of user:pass as a token', () => {
expect(detectCredentialType(base64('testuser:testpass'))).toBe(AUTH_TYPE.TOKEN);
});

it('treats a value that does not decode to printable ascii as an api key', () => {
expect(detectCredentialType('sk-live-abc123')).toBe(AUTH_TYPE.API_KEY);
});

it('treats base64 without a colon as an api key', () => {
expect(detectCredentialType(base64('nocolonhere'))).toBe(AUTH_TYPE.API_KEY);
});
});

describe('credentialFromValue', () => {
it('detects a token', () => {
expect(credentialFromValue(base64('user:pass'))).toEqual({
type: AUTH_TYPE.TOKEN,
value: base64('user:pass'),
});
});

it('detects an api key', () => {
expect(credentialFromValue('sk-live-abc123')).toEqual({
type: AUTH_TYPE.API_KEY,
value: 'sk-live-abc123',
});
});

it('trims surrounding whitespace', () => {
expect(credentialFromValue(' sk-live-abc123 ')?.value).toBe('sk-live-abc123');
});

it('returns undefined for empty and whitespace-only values', () => {
expect(credentialFromValue('')).toBeUndefined();
expect(credentialFromValue(' ')).toBeUndefined();
});
});

describe('credentialFromAuthHeader', () => {
it('maps Basic to a token', () => {
expect(credentialFromAuthHeader('Basic dGVzdDp0ZXN0')).toEqual({
type: AUTH_TYPE.TOKEN,
value: 'dGVzdDp0ZXN0',
});
});

it('maps Bearer to an api key', () => {
expect(credentialFromAuthHeader('Bearer sk-live-abc123')).toEqual({
type: AUTH_TYPE.API_KEY,
value: 'sk-live-abc123',
});
});

it('trusts the scheme over the shape of the value', () => {
expect(credentialFromAuthHeader(`Bearer ${base64('user:pass')}`)?.type).toBe(AUTH_TYPE.API_KEY);
});

it('rejects an unknown scheme', () => {
expect(credentialFromAuthHeader('Token abc123')).toBeUndefined();
});

it('is case sensitive on the scheme', () => {
expect(credentialFromAuthHeader('basic dGVzdDp0ZXN0')).toBeUndefined();
});

it('rejects a missing value, a missing scheme and extra parts', () => {
expect(credentialFromAuthHeader('Basic')).toBeUndefined();
expect(credentialFromAuthHeader('dGVzdDp0ZXN0')).toBeUndefined();
expect(credentialFromAuthHeader('Basic a b')).toBeUndefined();
});

it('tolerates padding whitespace', () => {
expect(credentialFromAuthHeader(' Basic dGVzdDp0ZXN0 ')).toEqual({
type: AUTH_TYPE.TOKEN,
value: 'dGVzdDp0ZXN0',
});
});
});
9 changes: 9 additions & 0 deletions src/auth/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const AUTH_TYPE = {
TOKEN: 'token',
API_KEY: 'apiKey',
} as const;

export const AUTH_SCHEME = {
BASIC: 'Basic',
BEARER: 'Bearer',
} as const;
31 changes: 31 additions & 0 deletions src/auth/credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { AUTH_SCHEME, AUTH_TYPE } from './constants';
import { detectCredentialType } from './detect-credential-type';
import type { AuthCredential, AuthType } from './types';

const TYPE_BY_SCHEME: Record<string, AuthType | undefined> = {
[AUTH_SCHEME.BASIC]: AUTH_TYPE.TOKEN,
[AUTH_SCHEME.BEARER]: AUTH_TYPE.API_KEY,
};

const toCredential = (type: AuthType | undefined, value: string): AuthCredential | undefined => {
const trimmed = value.trim();

if (!type || !trimmed) {
return;
}

return { type, value: trimmed };
};

export const credentialFromValue = (value: string): AuthCredential | undefined =>
toCredential(detectCredentialType(value), value);

export const credentialFromAuthHeader = (header: string): AuthCredential | undefined => {
const [scheme, value, ...rest] = header.trim().split(/\s+/);

if (!scheme || !value || rest.length > 0) {
return;
}

return toCredential(TYPE_BY_SCHEME[scheme], value);
};
14 changes: 14 additions & 0 deletions src/auth/detect-credential-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AUTH_TYPE } from './constants';
import type { AuthType } from './types';

const PRINTABLE_ASCII = /^[\x20-\x7e]+$/;

export const detectCredentialType = (value: string): AuthType => {
const decoded = Buffer.from(value, 'base64').toString('utf8');

if (PRINTABLE_ASCII.test(decoded) && decoded.includes(':')) {
return AUTH_TYPE.TOKEN;
}

return AUTH_TYPE.API_KEY;
};
4 changes: 4 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { AUTH_SCHEME, AUTH_TYPE } from './constants';
export { credentialFromAuthHeader, credentialFromValue } from './credential';
export { detectCredentialType } from './detect-credential-type';
export type { AuthCredential, AuthType } from './types';
8 changes: 8 additions & 0 deletions src/auth/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { AUTH_TYPE } from './constants';

export type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE];

export type AuthCredential = {
type: AuthType;
value: string;
};
33 changes: 31 additions & 2 deletions src/clients/__tests__/scraper-api-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { Target } from '@decodo/sdk-ts';
import { AUTH_TYPE } from '../../auth';
import type { AuthCredential } from '../../auth';
import { ScraperApiClient } from '../scraper-api-client';
import { ScrapingMCPParams } from '../../types';

const client = new ScraperApiClient({ maxRetries: 1, delayMs: 0 });

const auth = 'dGVzdDp0ZXN0';
const tokenValue = 'dGVzdDp0ZXN0';
const apiKeyValue = 'sk-test-api-key';

const auth: AuthCredential = { type: AUTH_TYPE.TOKEN, value: tokenValue };
const apiKeyAuth: AuthCredential = { type: AUTH_TYPE.API_KEY, value: apiKeyValue };

const defaultArgs = { auth, scrapingParams: { url: 'https://example.com' } };

const mockFetch = jest.fn();
Expand Down Expand Up @@ -53,11 +60,33 @@ describe('ScraperApiClient', () => {
expect(url).toBe('https://scraper-api.decodo.com/v2/scrape');
expect(init.method).toBe('POST');
expect(init.headers).toMatchObject({
Authorization: `Basic ${auth}`,
Authorization: `Basic ${tokenValue}`,
'x-integration': 'mcp',
});
});

it('posts to the data API with Bearer auth when given an API key', async () => {
await client.scrape({ ...defaultArgs, auth: apiKeyAuth });

const { url, init } = lastRequest();

expect(url).toBe('https://data.decodo.com/v1/scrape');
expect(init.headers).toMatchObject({
Authorization: `Bearer ${apiKeyValue}`,
'x-integration': 'mcp',
});
});

it('sends the same body on both transports', async () => {
await client.scrape(defaultArgs);
const tokenBody = requestBody();

await client.scrape({ ...defaultArgs, auth: apiKeyAuth });
const apiKeyBody = requestBody();

expect(apiKeyBody).toEqual(tokenBody);
});

it('returns the first result content', async () => {
respondWith(() => scrapeResponse({ title: 'Example' }));

Expand Down
20 changes: 17 additions & 3 deletions src/clients/scraper-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
} from '@decodo/sdk-ts';
import type { ScrapeRequest, SyncResponse } from '@decodo/sdk-ts';
import { ScrapingMCPParams } from 'types';
import { AUTH_TYPE } from '../auth';
import type { AuthCredential, AuthType } from '../auth';
import { ProgressNotifier, ProgressExtra } from '../utils';
import { log } from '../logger';
import {
Expand Down Expand Up @@ -81,10 +83,12 @@ export class ScraperApiClient {
private sdkError = ({
error,
target,
authType,
startMs,
}: {
error: unknown;
target: string;
authType: AuthType;
startMs: number;
}): unknown => {
const latencyMs = Date.now() - startMs;
Expand All @@ -97,6 +101,7 @@ export class ScraperApiClient {
log('error', 'tool_call', {
outcome: 'error',
target,
auth_type: authType,
error_type: 'upstream_api',
upstream_status: error.statusCode,
message: sdkMessage,
Expand All @@ -112,6 +117,7 @@ export class ScraperApiClient {
log('error', 'tool_call', {
outcome: 'error',
target,
auth_type: authType,
error_type: 'network',
error_code: errorCode,
message,
Expand All @@ -124,6 +130,7 @@ export class ScraperApiClient {
log('error', 'tool_call', {
outcome: 'error',
target,
auth_type: authType,
error_type: 'unexpected',
message,
latency_ms: latencyMs,
Expand All @@ -132,12 +139,15 @@ export class ScraperApiClient {
return error;
};

private sdkCredentials = (auth: AuthCredential) =>
auth.type === AUTH_TYPE.API_KEY ? { apiKey: auth.value } : { token: auth.value };

scrape = async <T = string>({
auth,
scrapingParams,
extra,
}: {
auth: string;
auth: AuthCredential;
scrapingParams: ScrapingMCPParams;
extra?: ProgressExtra;
}) => {
Expand All @@ -153,7 +163,10 @@ export class ScraperApiClient {
const { target } = params;

const { webScrapingApi } = new DecodoClient({
webScrapingApi: { token: auth, integrationHeader: INTEGRATION_HEADER },
webScrapingApi: {
...this.sdkCredentials(auth),
integrationHeader: INTEGRATION_HEADER,
},
timeoutMs: REQUEST_TIMEOUT_MS,
});

Expand All @@ -172,6 +185,7 @@ export class ScraperApiClient {
log('info', 'tool_call', {
outcome: 'success',
target,
auth_type: auth.type,
upstream_status: res.results[0]?.status_code ?? null,
latency_ms: Date.now() - startMs,
attempt,
Expand Down Expand Up @@ -208,7 +222,7 @@ export class ScraperApiClient {
}
}

throw this.sdkError({ error: lastError, target, startMs });
throw this.sdkError({ error: lastError, target, authType: auth.type, startMs });
} finally {
notifier.stopWaitingNotifications();
}
Expand Down
14 changes: 10 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import 'dotenv/config';

import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { credentialFromValue } from './auth';
import { ScraperAPIStdioServer } from './server/sapi-stdio-server';
import { resolveToolsets } from './utils';

Expand All @@ -15,9 +16,14 @@ const parseEnvsOrExit = () => {
}
}

return {
sapiAuth: process.env['SCRAPER_API_TOKEN'] as string,
};
const sapiAuth = credentialFromValue(process.env['SCRAPER_API_TOKEN'] as string);

if (!sapiAuth) {
console.error('env SCRAPER_API_TOKEN missing');
process.exit(1);
}

return { sapiAuth };
};

const main = async () => {
Expand All @@ -32,7 +38,7 @@ const main = async () => {
});
await sapiMcpServer.connect(transport);

console.error('MCP Server running on stdio');
console.error(`MCP Server running on stdio (auth: ${sapiAuth.type})`);
};

main().catch(error => {
Expand Down
11 changes: 5 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dotenv/config';
import cors from 'cors';
import express from 'express';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { credentialFromAuthHeader } from './auth';
import { corsOptions } from './server/cors';
import { ScraperAPIHttpServer } from './server/sapi-http-server';
import { resolveToolsets } from './utils';
Expand All @@ -23,18 +24,16 @@ app.post('/mcp', async (req, res) => {
return;
}

const parts = auth.split(' ');
const credential = credentialFromAuthHeader(auth);

if (parts.length !== 2 || parts[0] !== 'Basic') {
res.status(401).send("Valid 'Basic' authorization required");
if (!credential) {
res.status(401).send("Valid 'Basic' or 'Bearer' authorization required");
return;
}

const token = parts[1];

const toolsets = resolveToolsets(req.query.toolsets as string);

const server = new ScraperAPIHttpServer({ toolsets, auth: token });
const server = new ScraperAPIHttpServer({ toolsets, auth: credential });

const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
Expand Down
Loading