diff --git a/README.md b/README.md index 6f8e319..c4f60f5 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,9 @@ The `depscore` tool allows AI assistants to query the Socket API for dependency **Sample Response:** ``` pkg:npm/express@4.18.2: supply_chain: 1.0, quality: 0.9, maintenance: 1.0, vulnerability: 1.0, license: 1.0 + Report: https://socket.dev/npm/package/express pkg:pypi/fastapi@0.100.0: supply_chain: 1.0, quality: 0.95, maintenance: 0.98, vulnerability: 1.0, license: 1.0 + Report: https://socket.dev/pypi/package/fastapi ``` ### How to Use the Socket MCP Server diff --git a/index.ts b/index.ts index d824b01..5f2ffde 100755 --- a/index.ts +++ b/index.ts @@ -8,6 +8,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' import { randomUUID } from 'node:crypto' import { buildPurl } from './lib/purl.ts' import { deduplicateArtifacts } from './lib/artifacts.ts' +import { buildSocketReportUrl } from './lib/socket-url.ts' import { z } from 'zod' import pino from 'pino' import readline from 'readline' @@ -493,6 +494,7 @@ function createConfiguredServer (): McpServer { const ns = jsonData.namespace ? `${jsonData.namespace}/` : '' const purl: string = `pkg:${jsonData.type || 'unknown'}/${ns}${jsonData.name || 'unknown'}@${jsonData.version || 'unknown'}` if (jsonData.score && jsonData.score['overall'] !== undefined) { + const reportUrl = buildSocketReportUrl(jsonData) const scoreEntries = Object.entries(jsonData.score) .filter(([key]) => key !== 'overall' && key !== 'uuid') .map(([key, value]) => { @@ -502,7 +504,7 @@ function createConfiguredServer (): McpServer { }) .join(', ') - results.push(`${purl}: ${scoreEntries}`) + results.push(`${purl}: ${scoreEntries}\n Report: ${reportUrl}`) } else { results.push(`${purl}: No score found`) } @@ -512,6 +514,7 @@ function createConfiguredServer (): McpServer { const ns = jsonData.namespace ? `${jsonData.namespace}/` : '' const purl: string = `pkg:${jsonData.type || 'unknown'}/${ns}${jsonData.name || 'unknown'}@${jsonData.version || 'unknown'}` if (jsonData.score && jsonData.score.overall !== undefined) { + const reportUrl = buildSocketReportUrl(jsonData) const scoreEntries = Object.entries(jsonData.score) .filter(([key]) => key !== 'overall' && key !== 'uuid') .map(([key, value]) => { @@ -521,7 +524,7 @@ function createConfiguredServer (): McpServer { }) .join(', ') - results.push(`${purl}: ${scoreEntries}`) + results.push(`${purl}: ${scoreEntries}\n Report: ${reportUrl}`) } else { results.push(`${purl}: No score found`) } diff --git a/lib/socket-url.test.ts b/lib/socket-url.test.ts new file mode 100644 index 0000000..1ef0952 --- /dev/null +++ b/lib/socket-url.test.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import { test } from 'node:test' +import assert from 'node:assert' +import { buildSocketReportUrl } from './socket-url.ts' + +test('buildSocketReportUrl produces correct URLs across ecosystems', async (t) => { + await t.test('npm unscoped', () => { + assert.strictEqual( + buildSocketReportUrl({ type: 'npm', name: 'express' }), + 'https://socket.dev/npm/package/express' + ) + }) + + await t.test('npm scoped', () => { + assert.strictEqual( + buildSocketReportUrl({ type: 'npm', namespace: 'babel', name: 'core' }), + 'https://socket.dev/npm/package/@babel/core' + ) + }) + + await t.test('pypi', () => { + assert.strictEqual( + buildSocketReportUrl({ type: 'pypi', name: 'requests' }), + 'https://socket.dev/pypi/package/requests' + ) + }) + + await t.test('golang', () => { + assert.strictEqual( + buildSocketReportUrl({ type: 'golang', namespace: 'github.com/gin-gonic', name: 'gin' }), + 'https://socket.dev/golang/package/github.com/gin-gonic/gin' + ) + }) + + await t.test('maven', () => { + assert.strictEqual( + buildSocketReportUrl({ type: 'maven', namespace: 'org.apache.commons', name: 'commons-lang3' }), + 'https://socket.dev/maven/package/org.apache.commons/commons-lang3' + ) + }) + + await t.test('cargo', () => { + assert.strictEqual( + buildSocketReportUrl({ type: 'cargo', name: 'serde' }), + 'https://socket.dev/cargo/package/serde' + ) + }) + + await t.test('gem', () => { + assert.strictEqual( + buildSocketReportUrl({ type: 'gem', name: 'rails' }), + 'https://socket.dev/gem/package/rails' + ) + }) + + await t.test('nuget', () => { + assert.strictEqual( + buildSocketReportUrl({ type: 'nuget', name: 'Newtonsoft.Json' }), + 'https://socket.dev/nuget/package/Newtonsoft.Json' + ) + }) + + await t.test('handles unknown/missing data gracefully', () => { + assert.strictEqual( + buildSocketReportUrl({}), + 'https://socket.dev/npm/package/unknown' + ) + assert.strictEqual( + buildSocketReportUrl(null), + 'https://socket.dev/npm/package/unknown' + ) + }) +}) diff --git a/lib/socket-url.ts b/lib/socket-url.ts new file mode 100644 index 0000000..66d9713 --- /dev/null +++ b/lib/socket-url.ts @@ -0,0 +1,37 @@ +const SOCKET_REPORT_BASE = 'https://socket.dev' + +/** + * Build the Socket.dev report URL for a package so users can click through + * for deeper analysis when a score raises concerns. + */ +export function buildSocketReportUrl (data: unknown): string { + const obj = data && typeof data === 'object' ? data as Record : Object.create(null) + const type = obj.type + const name = obj.name + const namespace = obj.namespace + const ecosystem = (typeof type === 'string' ? type : 'npm').toLowerCase() + const pkgName = typeof name === 'string' ? name : 'unknown' + const ns = typeof namespace === 'string' ? namespace : undefined + + let packagePath: string + switch (ecosystem) { + case 'npm': + packagePath = ns ? `@${ns}/${pkgName}` : pkgName + break + case 'pypi': + case 'gem': + case 'nuget': + case 'cargo': + packagePath = pkgName + break + case 'golang': + case 'maven': + case 'composer': + packagePath = ns ? `${ns}/${pkgName}` : pkgName + break + default: + packagePath = ns ? `${ns}/${pkgName}` : pkgName + } + + return `${SOCKET_REPORT_BASE}/${ecosystem}/package/${packagePath}` +}