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
16 changes: 16 additions & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ export {
} from '../plugins/foundations'
export type { FoundationsPluginConfig } from '../plugins/foundations'

// Network Pack — HTTP + WebSocket tools. Opt-in; URL allowlist required.
export {
createNetworkPlugin,
UrlAllowlist,
WebSocketManager,
NETWORK_TOOLS,
httpRequest,
} from '../plugins/network'
export type {
NetworkPluginConfig,
HttpRequestArgs,
HttpResponse,
WebSocketSession,
QueuedMessage,
} from '../plugins/network'

// Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the
// default plugin set. Pass it explicitly via `createMcpServer({ plugins })`.
export {
Expand Down
79 changes: 79 additions & 0 deletions src/plugins/network/allowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// URL allowlist matching for the Network Pack.
//
// Patterns use shell-style globbing on hostname + path. Examples:
// "https://api.example.com/[asterisk]" — any path under that host
// "https://[asterisk].example.com/[asterisk]" — any subdomain
// "wss://[asterisk].realtime.example.com/[asterisk]" — WebSocket allowlist
//
// (literal "*" in patterns — written here as [asterisk] only because
// the closing "*/" of a JSDoc comment would otherwise be triggered.)
//
// Default allowlist is empty — requests will be refused with a helpful
// error until the operator explicitly configures one. This prevents the
// agent from exfiltrating data to arbitrary endpoints.

export interface Allowlist {
/** Glob-style patterns. Empty list = deny all. */
patterns: string[]
}

export class UrlAllowlist {
readonly patterns: string[]
private readonly compiled: RegExp[]

constructor(patterns: string[] = []) {
this.patterns = patterns
this.compiled = patterns.map((p) => globToRegex(p))
}

/** True if `url` matches at least one configured pattern. */
allows(url: string): boolean {
return this.compiled.some((re) => re.test(url))
}

/**
* Throws with a clear message if `url` is not allowed. Used at the
* boundary of every handler so the agent gets actionable feedback.
*/
assertAllowed(url: string): void {
if (this.patterns.length === 0) {
throw new Error(
'Network allowlist is empty. The agent cannot make HTTP or '
+ 'WebSocket calls until the operator configures one. Set '
+ '`urlAllowlist` on createNetworkPlugin() or AGENTMARK_HTTP_ALLOWLIST.',
)
}
if (!this.allows(url)) {
throw new Error(
`URL not in allowlist: ${url}. Configured patterns: `
+ this.patterns.map((p) => `"${p}"`).join(', '),
)
}
}
}

/**
* Convert a glob pattern to a regex. `*` matches any character except
* `/`; `**` matches any character including `/`. Other regex
* metacharacters are escaped.
*/
function globToRegex(pattern: string): RegExp {
let out = '^'
for (let i = 0; i < pattern.length; i++) {
const ch = pattern[i]
if (ch === '*') {
if (pattern[i + 1] === '*') {
out += '.*'
i++
} else {
out += '[^/]*'
}
} else if ('.+?^$(){}[]|\\'.includes(ch)) {
out += '\\' + ch
} else {
out += ch
}
}
out += '$'
return new RegExp(out)
}
128 changes: 128 additions & 0 deletions src/plugins/network/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* HTTP request handler for the Network Pack.
*
* Thin wrapper over global fetch (Node 18+/22+/browsers). Translates
* the agent-facing JSON args into a fetch call, applies the allowlist
* check, and returns a normalised response (status, headers, body).
*
* Body handling:
* - JSON object → stringified, content-type defaults to application/json
* - Plain string → sent verbatim
* - { base64: "..." } → decoded to bytes
*
* Response body:
* - 'text' (default) → returned as string
* - 'base64' → returned as base64-encoded bytes (use for binary)
* - 'json' → parsed (errors if response isn't valid JSON)
*/
import type { UrlAllowlist } from './allowlist'

export interface HttpRequestArgs {
url: string
method?: string
headers?: Record<string, string>
body?: unknown
response_format?: 'text' | 'base64' | 'json'
timeout_ms?: number
follow_redirects?: boolean
}

export interface HttpResponse {
status: number
status_text: string
url: string
headers: Record<string, string>
body: unknown
response_format: 'text' | 'base64' | 'json'
duration_ms: number
}

export async function httpRequest(
allowlist: UrlAllowlist,
args: HttpRequestArgs,
): Promise<HttpResponse> {
const url = args.url
if (!url) throw new Error('`url` is required.')
allowlist.assertAllowed(url)

const method = (args.method ?? 'GET').toUpperCase()
const headers: Record<string, string> = { ...(args.headers ?? {}) }
const responseFormat = args.response_format ?? 'text'
const followRedirects = args.follow_redirects !== false

const init: RequestInit = {
method,
headers,
redirect: followRedirects ? 'follow' : 'manual',
}

if (args.body !== undefined && args.body !== null) {
const { body, contentType } = encodeBody(args.body)
if (!hasHeader(headers, 'content-type') && contentType) {
headers['content-type'] = contentType
}
init.body = body
}

const controller = new AbortController()
const timeoutMs = args.timeout_ms ?? 30_000
const timer = setTimeout(() => controller.abort(), timeoutMs)
init.signal = controller.signal

const startedAt = Date.now()
let response: Response
try {
response = await fetch(url, init)
} finally {
clearTimeout(timer)
}
const duration_ms = Date.now() - startedAt

const responseHeaders: Record<string, string> = {}
response.headers.forEach((value, key) => { responseHeaders[key] = value })

let body: unknown
if (responseFormat === 'base64') {
const buf = await response.arrayBuffer()
body = Buffer.from(buf).toString('base64')
} else if (responseFormat === 'json') {
const text = await response.text()
try {
body = text.length > 0 ? JSON.parse(text) : null
} catch (err) {
throw new Error(
`response_format="json" but response body is not valid JSON: `
+ `${(err as Error).message}. Body preview: ${text.slice(0, 200)}`,
)
}
} else {
body = await response.text()
}

return {
status: response.status,
status_text: response.statusText,
url: response.url,
headers: responseHeaders,
body,
response_format: responseFormat,
duration_ms,
}
}

function encodeBody(body: unknown): { body: BodyInit; contentType?: string } {
if (typeof body === 'string') {
return { body }
}
if (body && typeof body === 'object' && 'base64' in (body as Record<string, unknown>)) {
const b64 = (body as { base64: string }).base64
const bytes = Buffer.from(b64, 'base64')
return { body: bytes as unknown as BodyInit, contentType: 'application/octet-stream' }
}
return { body: JSON.stringify(body), contentType: 'application/json' }
}

function hasHeader(headers: Record<string, string>, name: string): boolean {
const target = name.toLowerCase()
return Object.keys(headers).some((k) => k.toLowerCase() === target)
}
122 changes: 122 additions & 0 deletions src/plugins/network/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Network Pack — HTTP + WebSocket tools.
*
* Bounded by a URL allowlist (empty by default → denies everything).
* Configure via `urlAllowlist` config option or `AGENTMARK_HTTP_ALLOWLIST`
* (colon-separated patterns).
*/
import { UrlAllowlist } from './allowlist'
import { httpRequest } from './http'
import { WebSocketManager } from './websocket'
import { NETWORK_TOOLS } from './tool-defs'
import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../../mcp/plugin'

export interface NetworkPluginConfig {
/** Glob-style URL patterns allowed for HTTP + WebSocket requests.
* Empty list = deny all (default). */
urlAllowlist?: string[]
}

export function createNetworkPlugin(config: NetworkPluginConfig = {}): AgentMarkPlugin {
const fromEnv = (process.env.AGENTMARK_HTTP_ALLOWLIST ?? '')
.split(/[:,]/)
.map((s) => s.trim())
.filter(Boolean)
const patterns = [...(config.urlAllowlist ?? []), ...fromEnv]
const allowlist = new UrlAllowlist(patterns)
const wsManager = new WebSocketManager(allowlist)

const handlers: Record<string, ToolHandler> = {
agentmark_http_request: async (args): Promise<DispatchResult> => {
const url = requireString(args, 'url')
const response = await httpRequest(allowlist, {
url,
method: typeof args.method === 'string' ? args.method : undefined,
headers: isStringMap(args.headers) ? (args.headers as Record<string, string>) : undefined,
body: args.body,
response_format: args.response_format === 'json' || args.response_format === 'base64'
? args.response_format
: 'text',
timeout_ms: typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined,
follow_redirects: args.follow_redirects !== false,
})
return { text: JSON.stringify(response, null, 2) }
},

agentmark_websocket_connect: async (args): Promise<DispatchResult> => {
const url = requireString(args, 'url')
const protocols = optionalStringArray(args, 'protocols')
const result = await wsManager.connect(url, protocols)
return { text: JSON.stringify(result, null, 2) }
},

agentmark_websocket_send: async (args): Promise<DispatchResult> => {
const wsId = requireString(args, 'ws_id')
const data = requireString(args, 'data')
const format = args.format === 'base64' ? 'base64' : 'text'
wsManager.send(wsId, data, format)
return { text: JSON.stringify({ sent: true, ws_id: wsId, bytes: data.length }, null, 2) }
},

agentmark_websocket_receive: async (args): Promise<DispatchResult> => {
const wsId = requireString(args, 'ws_id')
const timeoutMs = typeof args.timeout_ms === 'number' ? args.timeout_ms : 5000
const max = typeof args.max === 'number' ? args.max : 100
const messages = await wsManager.receive(wsId, { timeoutMs, max })
return { text: JSON.stringify({ count: messages.length, messages }, null, 2) }
},

agentmark_websocket_close: async (args): Promise<DispatchResult> => {
const wsId = requireString(args, 'ws_id')
const code = typeof args.code === 'number' ? args.code : 1000
const reason = typeof args.reason === 'string' ? args.reason : undefined
await wsManager.close(wsId, code, reason)
return { text: JSON.stringify({ closed: true, ws_id: wsId, code }, null, 2) }
},
}

return {
name: 'network',
version: '0.1.0',
tools: NETWORK_TOOLS,
handlers,
dispose: async () => {
await wsManager.closeAll()
},
describeSessions: () => ({
network: {
url_allowlist: allowlist.patterns,
websockets: wsManager.list(),
},
}),
}
}

export { UrlAllowlist } from './allowlist'
export { httpRequest } from './http'
export { WebSocketManager } from './websocket'
export { NETWORK_TOOLS } from './tool-defs'
export type { HttpRequestArgs, HttpResponse } from './http'
export type { WebSocketSession, QueuedMessage } from './websocket'

function requireString(args: Record<string, unknown>, key: string): string {
const v = args[key]
if (typeof v !== 'string' || v.length === 0) {
throw new Error(`Missing required argument: ${key}`)
}
return v
}

function optionalStringArray(args: Record<string, unknown>, key: string): string[] | undefined {
const v = args[key]
if (v === undefined) return undefined
if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) {
throw new Error(`Argument ${key} must be an array of strings.`)
}
return v as string[]
}

function isStringMap(v: unknown): boolean {
if (!v || typeof v !== 'object' || Array.isArray(v)) return false
return Object.values(v as Record<string, unknown>).every((x) => typeof x === 'string')
}
Loading
Loading