sbox-api-mcp 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -3
- package/dist/data/ugc-client.d.ts +53 -0
- package/dist/data/ugc-client.js +26 -0
- package/dist/data/wiki-client.d.ts +11 -0
- package/dist/data/wiki-client.js +111 -0
- package/dist/index.js +10 -0
- package/dist/tools/get-package.d.ts +2 -0
- package/dist/tools/get-package.js +87 -0
- package/dist/tools/get-wiki-page.d.ts +2 -0
- package/dist/tools/get-wiki-page.js +32 -0
- package/dist/tools/search-packages.d.ts +2 -0
- package/dist/tools/search-packages.js +76 -0
- package/dist/tools/search-wiki.d.ts +2 -0
- package/dist/tools/search-wiki.js +68 -0
- package/package.json +8 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbox-api",
|
|
3
|
-
"description": "S&box game engine
|
|
3
|
+
"description": "S&box game engine MCP for Claude Code. Search API types, methods, and properties. Browse wiki documentation (guides, tutorials, concepts). Find Workshop community assets (models, sounds, maps, and more).",
|
|
4
4
|
"author": {
|
|
5
5
|
"name": "sofianebel"
|
|
6
6
|
}
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# sbox-api-mcp
|
|
2
2
|
|
|
3
|
-
MCP server for [S&box](https://sbox.game) game engine API documentation
|
|
3
|
+
MCP server for [S&box](https://sbox.game) game engine — API reference, wiki documentation, and Workshop assets. Indexes **1800+ types**, **15,000+ members**, and **8,000+ documentation entries** from the S&box API, plus **200+ wiki pages** (guides, tutorials, concepts) and community Workshop assets, all searchable directly from Claude Code.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -26,12 +26,19 @@ npx sbox-api-mcp
|
|
|
26
26
|
|
|
27
27
|
| Tool | Description | Example |
|
|
28
28
|
|------|-------------|---------|
|
|
29
|
+
| **API Reference** | | |
|
|
29
30
|
| `search_types` | Fuzzy search types (classes, structs, enums, interfaces) | `search_types({ query: "GameObject" })` |
|
|
30
31
|
| `get_type` | Full details of a specific type (methods, properties, fields) | `get_type({ name: "Sandbox.GameObject" })` |
|
|
31
32
|
| `search_members` | Search methods/properties across all types | `search_members({ query: "Position", kind: "property" })` |
|
|
32
33
|
| `list_namespaces` | List all API namespaces with type counts | `list_namespaces({ filter: "Audio" })` |
|
|
33
34
|
| `search_docs` | Full-text search in documentation summaries | `search_docs({ query: "play sound" })` |
|
|
34
35
|
| `update_api_source` | Update API data URL when S&box releases a new version | `update_api_source({ url: "https://cdn.sbox.game/releases/..." })` |
|
|
36
|
+
| **Wiki Documentation** | | |
|
|
37
|
+
| `search_wiki` | Search s&box wiki pages (guides, tutorials, concepts) | `search_wiki({ query: "networking" })` |
|
|
38
|
+
| `get_wiki_page` | Get the full content of a wiki page as markdown | `get_wiki_page({ path: "scene/components" })` |
|
|
39
|
+
| **Workshop (UGC)** | | |
|
|
40
|
+
| `search_packages` | Search community Workshop assets (models, sounds, maps, etc.) | `search_packages({ query: "zombie" })` |
|
|
41
|
+
| `get_package` | Get details of a specific Workshop package | `get_package({ ident: "facepunch.zombiemale" })` |
|
|
35
42
|
|
|
36
43
|
## Configuration
|
|
37
44
|
|
|
@@ -53,9 +60,11 @@ The URL format is: `https://cdn.sbox.game/releases/YYYY-MM-DD-HH-MM-SS.zip.json`
|
|
|
53
60
|
|
|
54
61
|
## How It Works
|
|
55
62
|
|
|
56
|
-
On first startup, the server downloads the S&box API JSON (~9 MB) and caches it locally in `~/.sbox-api-mcp/`. Subsequent startups use an ETag check to skip re-downloading if the data hasn't changed.
|
|
63
|
+
**API Reference:** On first startup, the server downloads the S&box API JSON (~9 MB) and caches it locally in `~/.sbox-api-mcp/`. Subsequent startups use an ETag check to skip re-downloading if the data hasn't changed. The API data is indexed in-memory using [Fuse.js](https://www.fusejs.io/) for fast fuzzy search across type names, member names, and documentation summaries.
|
|
57
64
|
|
|
58
|
-
The
|
|
65
|
+
**Wiki Documentation:** Leverages S&box's LLM-optimized endpoints (`llms.txt` index + `.md` raw markdown pages). The wiki index is fetched on first use and cached in-memory for 1 hour. Individual pages are fetched live from `sbox.game/dev/doc/` as raw markdown.
|
|
66
|
+
|
|
67
|
+
**Workshop (UGC):** Queries the Facepunch services API live for community-made assets.
|
|
59
68
|
|
|
60
69
|
## License
|
|
61
70
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export declare const PACKAGE_TYPES: readonly ["model", "sound", "material", "map", "game", "clothing", "library", "vsnd", "prefab", "shader"];
|
|
2
|
+
export type PackageType = (typeof PACKAGE_TYPES)[number];
|
|
3
|
+
export interface PackageOrg {
|
|
4
|
+
Ident: string;
|
|
5
|
+
Title: string;
|
|
6
|
+
Description?: string;
|
|
7
|
+
Thumb?: string;
|
|
8
|
+
Twitter?: string;
|
|
9
|
+
WebUrl?: string;
|
|
10
|
+
Discord?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface UgcPackage {
|
|
13
|
+
Org: PackageOrg;
|
|
14
|
+
Ident: string;
|
|
15
|
+
FullIdent: string;
|
|
16
|
+
Title: string;
|
|
17
|
+
Summary?: string;
|
|
18
|
+
Thumb?: string;
|
|
19
|
+
ThumbWide?: string;
|
|
20
|
+
ThumbTall?: string;
|
|
21
|
+
VideoThumb?: string;
|
|
22
|
+
TypeName: string;
|
|
23
|
+
Updated: string;
|
|
24
|
+
Created: string;
|
|
25
|
+
UsageStats?: Record<string, unknown>;
|
|
26
|
+
Tags?: string[];
|
|
27
|
+
Favourited?: number;
|
|
28
|
+
VotesUp?: number;
|
|
29
|
+
Collections?: number;
|
|
30
|
+
Referenced?: number;
|
|
31
|
+
Public?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface SearchPackagesResponse {
|
|
34
|
+
Packages: UgcPackage[];
|
|
35
|
+
TotalCount: number;
|
|
36
|
+
Facets: unknown[];
|
|
37
|
+
Tags?: Record<string, number>;
|
|
38
|
+
Orders?: Array<{
|
|
39
|
+
Name: string;
|
|
40
|
+
Title: string;
|
|
41
|
+
Icon: string;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
export interface SearchPackagesParams {
|
|
45
|
+
query: string;
|
|
46
|
+
type?: PackageType;
|
|
47
|
+
category?: string;
|
|
48
|
+
tag?: string;
|
|
49
|
+
order?: string;
|
|
50
|
+
take?: number;
|
|
51
|
+
skip?: number;
|
|
52
|
+
}
|
|
53
|
+
export declare function searchPackages(params: SearchPackagesParams): Promise<SearchPackagesResponse>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const UGC_API_BASE = 'https://services.facepunch.com/sbox/package';
|
|
2
|
+
export const PACKAGE_TYPES = [
|
|
3
|
+
'model', 'sound', 'material', 'map', 'game',
|
|
4
|
+
'clothing', 'library', 'vsnd', 'prefab', 'shader',
|
|
5
|
+
];
|
|
6
|
+
export async function searchPackages(params) {
|
|
7
|
+
const url = new URL(`${UGC_API_BASE}/find`);
|
|
8
|
+
url.searchParams.set('q', params.query);
|
|
9
|
+
if (params.type)
|
|
10
|
+
url.searchParams.set('type', params.type);
|
|
11
|
+
if (params.category)
|
|
12
|
+
url.searchParams.set('category', params.category);
|
|
13
|
+
if (params.tag)
|
|
14
|
+
url.searchParams.set('tag', params.tag);
|
|
15
|
+
if (params.order)
|
|
16
|
+
url.searchParams.set('order', params.order);
|
|
17
|
+
if (params.take)
|
|
18
|
+
url.searchParams.set('take', String(params.take));
|
|
19
|
+
if (params.skip)
|
|
20
|
+
url.searchParams.set('skip', String(params.skip));
|
|
21
|
+
const res = await fetch(url.toString());
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
throw new Error(`UGC API error: ${res.status} ${res.statusText}`);
|
|
24
|
+
}
|
|
25
|
+
return res.json();
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface WikiEntry {
|
|
2
|
+
title: string;
|
|
3
|
+
path: string;
|
|
4
|
+
section: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function searchWikiIndex(query: string, section?: string, limit?: number): Promise<WikiEntry[]>;
|
|
7
|
+
export declare function getWikiSections(): Promise<Map<string, number>>;
|
|
8
|
+
export declare function fetchWikiPage(path: string): Promise<{
|
|
9
|
+
content: string;
|
|
10
|
+
url: string;
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import Fuse from 'fuse.js';
|
|
2
|
+
const LLMS_TXT_URL = 'https://sbox.game/llms.txt';
|
|
3
|
+
const WIKI_BASE_URL = 'https://sbox.game';
|
|
4
|
+
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
|
5
|
+
const SECTION_LABELS = {
|
|
6
|
+
'getting-started': 'Getting Started',
|
|
7
|
+
'scene': 'Scene',
|
|
8
|
+
'code': 'Code',
|
|
9
|
+
'editor': 'Editor',
|
|
10
|
+
'assets': 'Assets',
|
|
11
|
+
'graphics': 'Graphics',
|
|
12
|
+
'ui': 'UI',
|
|
13
|
+
'gameplay': 'Gameplay',
|
|
14
|
+
'networking': 'Networking',
|
|
15
|
+
'services': 'Services',
|
|
16
|
+
};
|
|
17
|
+
let cache = null;
|
|
18
|
+
function parseLlmsTxt(raw) {
|
|
19
|
+
const entries = [];
|
|
20
|
+
const linkPattern = /^-\s+\[(.+?)]\((.+?)\)/;
|
|
21
|
+
for (const line of raw.split('\n')) {
|
|
22
|
+
const match = line.match(linkPattern);
|
|
23
|
+
if (!match)
|
|
24
|
+
continue;
|
|
25
|
+
const [, title, path] = match;
|
|
26
|
+
if (!path.startsWith('/dev/doc/'))
|
|
27
|
+
continue;
|
|
28
|
+
// Extract section from first path segment after /dev/doc/
|
|
29
|
+
const segments = path.replace('/dev/doc/', '').replace(/\.md$/, '').split('/');
|
|
30
|
+
const sectionSlug = segments[0];
|
|
31
|
+
const section = SECTION_LABELS[sectionSlug] ?? sectionSlug;
|
|
32
|
+
entries.push({ title, path, section });
|
|
33
|
+
}
|
|
34
|
+
return entries;
|
|
35
|
+
}
|
|
36
|
+
function buildWikiFuse(entries) {
|
|
37
|
+
return new Fuse(entries, {
|
|
38
|
+
keys: [
|
|
39
|
+
{ name: 'title', weight: 3 },
|
|
40
|
+
{ name: 'path', weight: 1 },
|
|
41
|
+
{ name: 'section', weight: 0.5 },
|
|
42
|
+
],
|
|
43
|
+
threshold: 0.4,
|
|
44
|
+
includeScore: true,
|
|
45
|
+
minMatchCharLength: 2,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function buildSectionCounts(entries) {
|
|
49
|
+
const sections = new Map();
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
sections.set(entry.section, (sections.get(entry.section) ?? 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
return sections;
|
|
54
|
+
}
|
|
55
|
+
async function fetchLlmsTxt() {
|
|
56
|
+
const res = await fetch(LLMS_TXT_URL);
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
throw new Error(`Failed to fetch llms.txt: ${res.status} ${res.statusText}`);
|
|
59
|
+
}
|
|
60
|
+
return res.text();
|
|
61
|
+
}
|
|
62
|
+
async function getOrBuildIndex() {
|
|
63
|
+
if (cache && Date.now() < cache.expiry) {
|
|
64
|
+
return cache;
|
|
65
|
+
}
|
|
66
|
+
console.error('[sbox-api] Fetching wiki index from llms.txt...');
|
|
67
|
+
const raw = await fetchLlmsTxt();
|
|
68
|
+
const entries = parseLlmsTxt(raw);
|
|
69
|
+
const fuse = buildWikiFuse(entries);
|
|
70
|
+
const sections = buildSectionCounts(entries);
|
|
71
|
+
console.error(`[sbox-api] Wiki index: ${entries.length} pages, ${sections.size} sections`);
|
|
72
|
+
cache = { entries, fuse, sections, expiry: Date.now() + CACHE_TTL };
|
|
73
|
+
return cache;
|
|
74
|
+
}
|
|
75
|
+
export async function searchWikiIndex(query, section, limit = 10) {
|
|
76
|
+
const index = await getOrBuildIndex();
|
|
77
|
+
if (!query) {
|
|
78
|
+
let entries = index.entries;
|
|
79
|
+
if (section) {
|
|
80
|
+
entries = entries.filter(e => e.section.toLowerCase() === section.toLowerCase());
|
|
81
|
+
}
|
|
82
|
+
return entries.slice(0, limit);
|
|
83
|
+
}
|
|
84
|
+
const results = index.fuse.search(query, { limit: limit * 2 });
|
|
85
|
+
let entries = results.map(r => r.item);
|
|
86
|
+
if (section) {
|
|
87
|
+
entries = entries.filter(e => e.section.toLowerCase() === section.toLowerCase());
|
|
88
|
+
}
|
|
89
|
+
return entries.slice(0, limit);
|
|
90
|
+
}
|
|
91
|
+
export async function getWikiSections() {
|
|
92
|
+
const index = await getOrBuildIndex();
|
|
93
|
+
return index.sections;
|
|
94
|
+
}
|
|
95
|
+
export async function fetchWikiPage(path) {
|
|
96
|
+
// Normalize path: accept various formats
|
|
97
|
+
let normalized = path.trim();
|
|
98
|
+
// Strip leading /dev/doc/ if present
|
|
99
|
+
normalized = normalized.replace(/^\/dev\/doc\//, '');
|
|
100
|
+
// Strip leading slash
|
|
101
|
+
normalized = normalized.replace(/^\//, '');
|
|
102
|
+
// Strip trailing .md
|
|
103
|
+
normalized = normalized.replace(/\.md$/, '');
|
|
104
|
+
const url = `${WIKI_BASE_URL}/dev/doc/${normalized}.md`;
|
|
105
|
+
const res = await fetch(url);
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
throw new Error(`Wiki page not found: ${res.status} ${res.statusText} (${url})`);
|
|
108
|
+
}
|
|
109
|
+
const content = await res.text();
|
|
110
|
+
return { content, url };
|
|
111
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,10 @@ import { registerSearchMembers } from './tools/search-members.js';
|
|
|
9
9
|
import { registerListNamespaces } from './tools/list-namespaces.js';
|
|
10
10
|
import { registerSearchDocs } from './tools/search-docs.js';
|
|
11
11
|
import { registerUpdateSource } from './tools/update-source.js';
|
|
12
|
+
import { registerSearchPackages } from './tools/search-packages.js';
|
|
13
|
+
import { registerGetPackage } from './tools/get-package.js';
|
|
14
|
+
import { registerSearchWiki } from './tools/search-wiki.js';
|
|
15
|
+
import { registerGetWikiPage } from './tools/get-wiki-page.js';
|
|
12
16
|
async function main() {
|
|
13
17
|
console.error('[sbox-api] Starting S&box API MCP Server...');
|
|
14
18
|
// Load API data
|
|
@@ -26,6 +30,12 @@ async function main() {
|
|
|
26
30
|
registerListNamespaces(server, indexes);
|
|
27
31
|
registerSearchDocs(server, indexes);
|
|
28
32
|
registerUpdateSource(server, indexes);
|
|
33
|
+
// UGC Workshop tools (no indexes needed - live API)
|
|
34
|
+
registerSearchPackages(server);
|
|
35
|
+
registerGetPackage(server);
|
|
36
|
+
// Wiki documentation tools (live API, in-memory cached index)
|
|
37
|
+
registerSearchWiki(server);
|
|
38
|
+
registerGetWikiPage(server);
|
|
29
39
|
// Connect via stdio
|
|
30
40
|
const transport = new StdioServerTransport();
|
|
31
41
|
await server.connect(transport);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { searchPackages } from '../data/ugc-client.js';
|
|
3
|
+
function formatPackageDetails(pkg) {
|
|
4
|
+
const sections = [];
|
|
5
|
+
sections.push(`# ${pkg.Title}`);
|
|
6
|
+
sections.push(`**Identifier:** \`${pkg.FullIdent}\``);
|
|
7
|
+
sections.push(`**Type:** ${pkg.TypeName}`);
|
|
8
|
+
if (pkg.Summary)
|
|
9
|
+
sections.push(`**Summary:** ${pkg.Summary}`);
|
|
10
|
+
sections.push(`**Author:** ${pkg.Org.Title} (${pkg.Org.Ident})`);
|
|
11
|
+
sections.push('');
|
|
12
|
+
sections.push('## Stats');
|
|
13
|
+
if (pkg.VotesUp)
|
|
14
|
+
sections.push(`- Votes: ${pkg.VotesUp}`);
|
|
15
|
+
if (pkg.Favourited)
|
|
16
|
+
sections.push(`- Favourited: ${pkg.Favourited}`);
|
|
17
|
+
if (pkg.Collections)
|
|
18
|
+
sections.push(`- In collections: ${pkg.Collections}`);
|
|
19
|
+
if (pkg.Referenced)
|
|
20
|
+
sections.push(`- Referenced by: ${pkg.Referenced}`);
|
|
21
|
+
sections.push('');
|
|
22
|
+
sections.push('## Dates');
|
|
23
|
+
sections.push(`- Created: ${pkg.Created}`);
|
|
24
|
+
sections.push(`- Updated: ${pkg.Updated}`);
|
|
25
|
+
if (pkg.Tags?.length) {
|
|
26
|
+
sections.push('');
|
|
27
|
+
sections.push(`## Tags\n${pkg.Tags.join(', ')}`);
|
|
28
|
+
}
|
|
29
|
+
if (pkg.Thumb) {
|
|
30
|
+
sections.push('');
|
|
31
|
+
sections.push(`## Thumbnail\n${pkg.Thumb}`);
|
|
32
|
+
}
|
|
33
|
+
sections.push('');
|
|
34
|
+
sections.push(`## Usage\nUse \`${pkg.FullIdent}\` in your S&box project to reference this package.`);
|
|
35
|
+
sections.push(`Workshop page: https://sbox.game/${pkg.Org.Ident}/${pkg.Ident}`);
|
|
36
|
+
return sections.join('\n');
|
|
37
|
+
}
|
|
38
|
+
export function registerGetPackage(server) {
|
|
39
|
+
server.registerTool('get_package', {
|
|
40
|
+
title: 'Get S&box Package Details',
|
|
41
|
+
description: 'Get detailed information about a specific S&box Workshop package by its identifier (e.g., "facepunch.zombiemale"). Returns full metadata including stats, tags, author info, and usage instructions.',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
ident: z
|
|
44
|
+
.string()
|
|
45
|
+
.describe("Package identifier in 'org.name' format (e.g., 'facepunch.zombiemale', 'fpopium.chair_01')"),
|
|
46
|
+
},
|
|
47
|
+
}, async ({ ident }) => {
|
|
48
|
+
try {
|
|
49
|
+
// The API doesn't have a direct get-by-id endpoint,
|
|
50
|
+
// so we search with the exact ident and filter
|
|
51
|
+
const parts = ident.split('.');
|
|
52
|
+
const searchQuery = parts.length > 1 ? parts[1] : ident;
|
|
53
|
+
const response = await searchPackages({ query: searchQuery, take: 20 });
|
|
54
|
+
const pkg = response.Packages.find(p => p.FullIdent.toLowerCase() === ident.toLowerCase());
|
|
55
|
+
if (!pkg) {
|
|
56
|
+
// Try broader search
|
|
57
|
+
const broader = await searchPackages({ query: ident, take: 20 });
|
|
58
|
+
const found = broader.Packages.find(p => p.FullIdent.toLowerCase() === ident.toLowerCase());
|
|
59
|
+
if (!found) {
|
|
60
|
+
const suggestions = response.Packages.slice(0, 5)
|
|
61
|
+
.map(p => ` - ${p.FullIdent} ("${p.Title}")`)
|
|
62
|
+
.join('\n');
|
|
63
|
+
return {
|
|
64
|
+
content: [{
|
|
65
|
+
type: 'text',
|
|
66
|
+
text: `Package "${ident}" not found.\n\nSimilar packages:\n${suggestions}`,
|
|
67
|
+
}],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
content: [{ type: 'text', text: formatPackageDetails(found) }],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: 'text', text: formatPackageDetails(pkg) }],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return {
|
|
80
|
+
content: [{
|
|
81
|
+
type: 'text',
|
|
82
|
+
text: `Error fetching package: ${err instanceof Error ? err.message : String(err)}`,
|
|
83
|
+
}],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { fetchWikiPage } from '../data/wiki-client.js';
|
|
3
|
+
export function registerGetWikiPage(server) {
|
|
4
|
+
server.registerTool('get_wiki_page', {
|
|
5
|
+
title: 'Get S&box Wiki Page',
|
|
6
|
+
description: 'Fetch a specific s&box wiki documentation page as raw markdown. Use search_wiki to find page paths, or provide a known path directly.',
|
|
7
|
+
inputSchema: {
|
|
8
|
+
path: z
|
|
9
|
+
.string()
|
|
10
|
+
.describe("Wiki page path (e.g., 'scene/components', 'networking/rpc-messages', 'getting-started/first-project')"),
|
|
11
|
+
},
|
|
12
|
+
}, async ({ path }) => {
|
|
13
|
+
try {
|
|
14
|
+
const { content, url } = await fetchWikiPage(path);
|
|
15
|
+
return {
|
|
16
|
+
content: [{
|
|
17
|
+
type: 'text',
|
|
18
|
+
text: `Source: ${url}\n\n---\n\n${content}`,
|
|
19
|
+
}],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
24
|
+
return {
|
|
25
|
+
content: [{
|
|
26
|
+
type: 'text',
|
|
27
|
+
text: `Error fetching wiki page: ${message}\n\nTip: Use search_wiki to find valid page paths.`,
|
|
28
|
+
}],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { searchPackages, PACKAGE_TYPES } from '../data/ugc-client.js';
|
|
3
|
+
function formatPackage(pkg, index) {
|
|
4
|
+
const lines = [];
|
|
5
|
+
const votes = pkg.VotesUp ? ` | Votes: ${pkg.VotesUp}` : '';
|
|
6
|
+
const favs = pkg.Favourited ? ` | Favs: ${pkg.Favourited}` : '';
|
|
7
|
+
const tags = pkg.Tags?.length ? `Tags: ${pkg.Tags.join(', ')}` : '';
|
|
8
|
+
lines.push(`${index + 1}. **${pkg.FullIdent}** — "${pkg.Title}"`);
|
|
9
|
+
if (pkg.Summary)
|
|
10
|
+
lines.push(` ${pkg.Summary}`);
|
|
11
|
+
lines.push(` Type: ${pkg.TypeName}${votes}${favs}`);
|
|
12
|
+
if (tags)
|
|
13
|
+
lines.push(` ${tags}`);
|
|
14
|
+
lines.push(` Use in code: \`${pkg.FullIdent}\``);
|
|
15
|
+
return lines.join('\n');
|
|
16
|
+
}
|
|
17
|
+
export function registerSearchPackages(server) {
|
|
18
|
+
server.registerTool('search_packages', {
|
|
19
|
+
title: 'Search S&box Workshop Packages',
|
|
20
|
+
description: 'Search the S&box Workshop (UGC) for community-made assets: models, sounds, materials, maps, prefabs, shaders, clothing, and more. Returns packages with their identifiers that can be used directly in S&box game code.',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
query: z.string().describe("Search query (e.g., 'zombie', 'wooden chair', 'footstep sound')"),
|
|
23
|
+
type: z
|
|
24
|
+
.enum(PACKAGE_TYPES)
|
|
25
|
+
.optional()
|
|
26
|
+
.describe('Filter by asset type: model, sound, material, map, game, clothing, library, vsnd, prefab, shader'),
|
|
27
|
+
category: z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Filter by category (for models: Animal, Architecture, Debris, Development, Fence/Wall, Food, Furniture, Human, Lighting, Nature, Prop, Toy, Vehicle, Weapon)'),
|
|
31
|
+
tag: z.string().optional().describe("Filter by tag (e.g., 'kenney', 'retro', 'psx', 'realistic')"),
|
|
32
|
+
order: z
|
|
33
|
+
.enum(['popular', 'newest', 'updated', 'trending', 'thumbsup', 'favourites'])
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Sort order. Defaults to relevance.'),
|
|
36
|
+
take: z.number().min(1).max(50).default(10).describe('Max results (1-50, default 10)'),
|
|
37
|
+
},
|
|
38
|
+
}, async ({ query, type, category, tag, order, take }) => {
|
|
39
|
+
try {
|
|
40
|
+
const response = await searchPackages({ query, type, category, tag, order, take });
|
|
41
|
+
if (response.Packages.length === 0) {
|
|
42
|
+
return {
|
|
43
|
+
content: [{
|
|
44
|
+
type: 'text',
|
|
45
|
+
text: `No packages found matching "${query}"${type ? ` (type: ${type})` : ''}. Try broadening your search or removing filters.`,
|
|
46
|
+
}],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const formatted = response.Packages.map((pkg, i) => formatPackage(pkg, i));
|
|
50
|
+
const typeLabel = type ? ` (type: ${type})` : '';
|
|
51
|
+
const header = `Found ${response.TotalCount} packages matching "${query}"${typeLabel} (showing ${response.Packages.length}):\n`;
|
|
52
|
+
const topTags = response.Tags
|
|
53
|
+
? Object.entries(response.Tags)
|
|
54
|
+
.sort(([, a], [, b]) => b - a)
|
|
55
|
+
.slice(0, 10)
|
|
56
|
+
.map(([t, c]) => `${t} (${c})`)
|
|
57
|
+
.join(', ')
|
|
58
|
+
: null;
|
|
59
|
+
const footer = topTags ? `\n---\nTop tags in results: ${topTags}` : '';
|
|
60
|
+
return {
|
|
61
|
+
content: [{
|
|
62
|
+
type: 'text',
|
|
63
|
+
text: `${header}\n${formatted.join('\n\n')}${footer}`,
|
|
64
|
+
}],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return {
|
|
69
|
+
content: [{
|
|
70
|
+
type: 'text',
|
|
71
|
+
text: `Error searching packages: ${err instanceof Error ? err.message : String(err)}`,
|
|
72
|
+
}],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { searchWikiIndex, getWikiSections } from '../data/wiki-client.js';
|
|
3
|
+
function formatEntry(entry, index) {
|
|
4
|
+
const slug = entry.path.replace('/dev/doc/', '').replace(/\.md$/, '');
|
|
5
|
+
return `${index + 1}. **${entry.title}** [${entry.section}]\n Path: ${slug}`;
|
|
6
|
+
}
|
|
7
|
+
export function registerSearchWiki(server) {
|
|
8
|
+
server.registerTool('search_wiki', {
|
|
9
|
+
title: 'Search S&box Wiki Documentation',
|
|
10
|
+
description: 'Search the s&box wiki documentation for guides, tutorials, and conceptual docs. Returns matching page titles with paths. Use get_wiki_page with a path to read a specific page.',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
query: z
|
|
13
|
+
.string()
|
|
14
|
+
.default('')
|
|
15
|
+
.describe("Topic to search for (e.g., 'networking', 'components', 'shader graph'). Leave empty to list sections or browse."),
|
|
16
|
+
section: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Filter by documentation section (e.g., 'Scene', 'Networking', 'Graphics', 'UI', 'Editor', 'Gameplay', 'Getting Started')"),
|
|
20
|
+
limit: z.number().min(1).max(50).default(10).describe('Max results (1-50, default 10)'),
|
|
21
|
+
},
|
|
22
|
+
}, async ({ query, section, limit }) => {
|
|
23
|
+
try {
|
|
24
|
+
// No query and no section: show sections overview
|
|
25
|
+
if (!query && !section) {
|
|
26
|
+
const sections = await getWikiSections();
|
|
27
|
+
const lines = Array.from(sections.entries())
|
|
28
|
+
.sort(([, a], [, b]) => b - a)
|
|
29
|
+
.map(([name, count]) => `- **${name}** (${count} pages)`);
|
|
30
|
+
const total = Array.from(sections.values()).reduce((a, b) => a + b, 0);
|
|
31
|
+
return {
|
|
32
|
+
content: [{
|
|
33
|
+
type: 'text',
|
|
34
|
+
text: `S&box Wiki Documentation — ${total} pages in ${sections.size} sections:\n\n${lines.join('\n')}\n\nTip: Use search_wiki with a query or section to find specific pages.`,
|
|
35
|
+
}],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const results = await searchWikiIndex(query, section, limit);
|
|
39
|
+
if (results.length === 0) {
|
|
40
|
+
const hint = section ? ` in section "${section}"` : '';
|
|
41
|
+
return {
|
|
42
|
+
content: [{
|
|
43
|
+
type: 'text',
|
|
44
|
+
text: `No wiki pages found matching "${query}"${hint}. Try broadening your search or removing the section filter.`,
|
|
45
|
+
}],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const formatted = results.map((entry, i) => formatEntry(entry, i));
|
|
49
|
+
const sectionLabel = section ? ` in "${section}"` : '';
|
|
50
|
+
const queryLabel = query ? `matching "${query}"` : '';
|
|
51
|
+
const header = `Found ${results.length} wiki pages ${queryLabel}${sectionLabel}:\n`;
|
|
52
|
+
return {
|
|
53
|
+
content: [{
|
|
54
|
+
type: 'text',
|
|
55
|
+
text: `${header}\n${formatted.join('\n\n')}\n\nTip: Use get_wiki_page with the path to read a page.`,
|
|
56
|
+
}],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return {
|
|
61
|
+
content: [{
|
|
62
|
+
type: 'text',
|
|
63
|
+
text: `Error searching wiki: ${err instanceof Error ? err.message : String(err)}`,
|
|
64
|
+
}],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbox-api-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "MCP server for S&box game engine API documentation
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "MCP server for S&box game engine — API reference, wiki documentation, and Workshop (UGC) asset search. Search types, methods, properties, wiki guides/tutorials, and community-made models, sounds, maps, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"sbox-api-mcp": "dist/index.js"
|
|
@@ -25,7 +25,12 @@
|
|
|
25
25
|
"api",
|
|
26
26
|
"documentation",
|
|
27
27
|
"claude",
|
|
28
|
-
"claude-code"
|
|
28
|
+
"claude-code",
|
|
29
|
+
"wiki",
|
|
30
|
+
"ugc",
|
|
31
|
+
"workshop",
|
|
32
|
+
"assets",
|
|
33
|
+
"models"
|
|
29
34
|
],
|
|
30
35
|
"author": "sofianebel",
|
|
31
36
|
"license": "MIT",
|