@foro-sh/foro 0.14.0 → 0.16.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/README.md +20 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/manifest-cases.d.ts +2 -21
- package/dist/manifest-cases.d.ts.map +1 -1
- package/dist/manifest-cases.js +1 -14
- package/dist/skills.d.ts +18 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +131 -0
- package/package.json +19 -2
package/README.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
This package is the TypeScript SDK for Foro.
|
|
4
4
|
|
|
5
|
+
## `skills(server, dir = 'skills')`
|
|
6
|
+
|
|
7
|
+
Serves every `<dir>/<name>/SKILL.md` as an
|
|
8
|
+
[Agent Skill over MCP](https://modelcontextprotocol.io/extensions/skills/overview):
|
|
9
|
+
the `io.modelcontextprotocol/skills` capability, `skills/list` and `skills/get`
|
|
10
|
+
with a SHA-256 manifest per skill, and each file over `resources/read` at
|
|
11
|
+
`skill://<name>/<file>`.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
15
|
+
import { skills } from '@foro-sh/foro'
|
|
16
|
+
|
|
17
|
+
const server = new McpServer({ name: 'my-server', version: '0.1.0' })
|
|
18
|
+
skills(server) // before server.connect()
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Needs `@modelcontextprotocol/sdk` 1.31+ and `zod` 4 alongside it. The
|
|
22
|
+
directory is scanned once per process, and a skill whose frontmatter `name`
|
|
23
|
+
isn't its directory name throws at startup rather than being served.
|
|
24
|
+
|
|
5
25
|
## `@foro-sh/foro/manifest-cases`
|
|
6
26
|
|
|
7
27
|
The shared project-config validation table, as typed data. Foro's Python
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,wBAAgB,SAAS,CAAC,IAAI,GAAE,MAAgB,GAAG,MAAM,CAExD"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,wBAAgB,SAAS,CAAC,IAAI,GAAE,MAAgB,GAAG,MAAM,CAExD;AACD,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
package/dist/manifest-cases.d.ts
CHANGED
|
@@ -1,27 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* foro's `_manifest.py` is a port of foro-sh/platform's
|
|
5
|
-
* `apps/api/src/services/manifest.ts`, and the two can never be allowed to
|
|
6
|
-
* silently disagree about what a valid manifest is - if they do, `foro check`
|
|
7
|
-
* passes locally and the deploy fails, which is the exact gap this SDK exists
|
|
8
|
-
* to close. Both sides therefore run the same table: the Python package
|
|
9
|
-
* through `tests/test_manifest_cases.py`, the platform by importing
|
|
10
|
-
* `manifestCases` from here (foro-sh/foro#5).
|
|
11
|
-
*
|
|
12
|
-
* The cases assert only accept/reject and the rejection reason - the resolved
|
|
13
|
-
* defaults each implementation produces stay covered by its own tests.
|
|
14
|
-
*/
|
|
15
|
-
/** Mirrors `ManifestRejectionReason` in foro-sh/platform's `@foro/types`.
|
|
16
|
-
* Kept in sync deliberately: it is what makes a reason added on one side a
|
|
17
|
-
* compile error on the other. */
|
|
1
|
+
/** Shared project-config validation table. Imported by foro-sh/platform. */
|
|
2
|
+
/** Mirrors `ManifestRejectionReason` in foro-sh/platform's `@foro/types`. */
|
|
18
3
|
export type ManifestRejectionReason = 'missing_manifest' | 'unsupported_language' | 'invalid_yaml' | 'invalid_shape' | 'invalid_name' | 'invalid_entrypoint' | 'invalid_build_path' | 'invalid_runtime' | 'invalid_runtime_version' | 'invalid_port' | 'invalid_dependency_manager' | 'unsupported_project' | 'unknown_field' | 'invalid_egress';
|
|
19
4
|
export interface ManifestCase {
|
|
20
|
-
/** Stable identifier, unique across the table - use it as the test name. */
|
|
21
5
|
readonly name: string;
|
|
22
|
-
/** Files to write into an empty directory before validating it, keyed by
|
|
23
|
-
* repo-relative path. Always the config file under test, plus whatever the
|
|
24
|
-
* entry-file inference has to find on disk. */
|
|
25
6
|
readonly files: Readonly<Record<string, string>>;
|
|
26
7
|
readonly expect: {
|
|
27
8
|
readonly ok: true;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest-cases.d.ts","sourceRoot":"","sources":["../src/manifest-cases.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"manifest-cases.d.ts","sourceRoot":"","sources":["../src/manifest-cases.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAI5E,6EAA6E;AAC7E,MAAM,MAAM,uBAAuB,GAC/B,kBAAkB,GAClB,sBAAsB,GACtB,cAAc,GACd,eAAe,GACf,cAAc,GACd,oBAAoB,GACpB,oBAAoB,GACpB,iBAAiB,GACjB,yBAAyB,GACzB,cAAc,GACd,4BAA4B,GAC5B,qBAAqB,GACrB,eAAe,GACf,gBAAgB,CAAA;AAEpB,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IAChD,QAAQ,CAAC,MAAM,EACX;QAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAA;KAAE,GACrB;QAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,uBAAuB,CAAA;KAAE,CAAA;CACrE;AAED,eAAO,MAAM,aAAa,EAAE,SAAS,YAAY,EAAqB,CAAA"}
|
package/dist/manifest-cases.js
CHANGED
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The shared project-config validation table.
|
|
3
|
-
*
|
|
4
|
-
* foro's `_manifest.py` is a port of foro-sh/platform's
|
|
5
|
-
* `apps/api/src/services/manifest.ts`, and the two can never be allowed to
|
|
6
|
-
* silently disagree about what a valid manifest is - if they do, `foro check`
|
|
7
|
-
* passes locally and the deploy fails, which is the exact gap this SDK exists
|
|
8
|
-
* to close. Both sides therefore run the same table: the Python package
|
|
9
|
-
* through `tests/test_manifest_cases.py`, the platform by importing
|
|
10
|
-
* `manifestCases` from here (foro-sh/foro#5).
|
|
11
|
-
*
|
|
12
|
-
* The cases assert only accept/reject and the rejection reason - the resolved
|
|
13
|
-
* defaults each implementation produces stay covered by its own tests.
|
|
14
|
-
*/
|
|
1
|
+
/** Shared project-config validation table. Imported by foro-sh/platform. */
|
|
15
2
|
import { rawManifestCases } from './_generated-manifest-cases.js';
|
|
16
3
|
export const manifestCases = rawManifestCases;
|
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export declare const SKILLS_EXTENSION = "io.modelcontextprotocol/skills";
|
|
3
|
+
export interface SkillEntry {
|
|
4
|
+
uri: string;
|
|
5
|
+
frontmatter: Record<string, unknown>;
|
|
6
|
+
resources: {
|
|
7
|
+
uri: string;
|
|
8
|
+
digest: string;
|
|
9
|
+
size: number;
|
|
10
|
+
}[];
|
|
11
|
+
}
|
|
12
|
+
/** Serve every `<dir>/<name>/SKILL.md` as an MCP skill (SEP-2640): the
|
|
13
|
+
* `io.modelcontextprotocol/skills` capability, `skills/list` and `skills/get`
|
|
14
|
+
* with a SHA-256 manifest per skill, and each file over `resources/read` at
|
|
15
|
+
* `skill://<name>/<path>`. Call before `connect`. Throws rather than serving a
|
|
16
|
+
* skill a host would reject. */
|
|
17
|
+
export declare function skills(server: McpServer, dir?: string): void;
|
|
18
|
+
//# sourceMappingURL=skills.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../src/skills.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAA;AAKxE,eAAO,MAAM,gBAAgB,mCAAmC,CAAA;AAchE,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAA;IACX,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACpC,SAAS,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC3D;AAiBD;;;;iCAIiC;AACjC,wBAAgB,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,SAAW,GAAG,IAAI,CA+B9D"}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { isUtf8 } from 'node:buffer';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { extname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { McpError, ErrorCode, RequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { parse } from 'yaml';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
export const SKILLS_EXTENSION = 'io.modelcontextprotocol/skills';
|
|
9
|
+
// Manifests are computed once from the files on disk, so an entry stays valid
|
|
10
|
+
// for as long as the process does. Five minutes is the spec's own example;
|
|
11
|
+
// nothing here is per-user, hence public.
|
|
12
|
+
const CACHE = { ttlMs: 300_000, cacheScope: 'public' };
|
|
13
|
+
const ListSkillsSchema = RequestSchema.extend({ method: z.literal('skills/list') });
|
|
14
|
+
const GetSkillSchema = RequestSchema.extend({
|
|
15
|
+
method: z.literal('skills/get'),
|
|
16
|
+
params: z.looseObject({ uri: z.string() }),
|
|
17
|
+
});
|
|
18
|
+
// A stateless server builds a fresh McpServer per request, so the scan (and
|
|
19
|
+
// every digest) is done once per directory rather than on every call.
|
|
20
|
+
const scanned = new Map();
|
|
21
|
+
/** Serve every `<dir>/<name>/SKILL.md` as an MCP skill (SEP-2640): the
|
|
22
|
+
* `io.modelcontextprotocol/skills` capability, `skills/list` and `skills/get`
|
|
23
|
+
* with a SHA-256 manifest per skill, and each file over `resources/read` at
|
|
24
|
+
* `skill://<name>/<path>`. Call before `connect`. Throws rather than serving a
|
|
25
|
+
* skill a host would reject. */
|
|
26
|
+
export function skills(server, dir = 'skills') {
|
|
27
|
+
const root = resolve(dir);
|
|
28
|
+
let found = scanned.get(root);
|
|
29
|
+
if (!found) {
|
|
30
|
+
found = scan(root);
|
|
31
|
+
scanned.set(root, found);
|
|
32
|
+
}
|
|
33
|
+
const byUri = new Map(found.map((skill) => [skill.entry.uri, skill.entry]));
|
|
34
|
+
const listing = found.map((skill) => skill.entry);
|
|
35
|
+
server.server.registerCapabilities({ resources: {}, extensions: { [SKILLS_EXTENSION]: {} } });
|
|
36
|
+
// ponytail: one page, every skill. Fine at a handful; add a cursor when a
|
|
37
|
+
// server ships enough that one response gets heavy.
|
|
38
|
+
server.server.setRequestHandler(ListSkillsSchema, () => ({
|
|
39
|
+
resultType: 'complete',
|
|
40
|
+
skills: listing,
|
|
41
|
+
...CACHE,
|
|
42
|
+
}));
|
|
43
|
+
server.server.setRequestHandler(GetSkillSchema, (request) => {
|
|
44
|
+
const entry = byUri.get(request.params.uri);
|
|
45
|
+
if (!entry)
|
|
46
|
+
throw new McpError(ErrorCode.InvalidParams, `Unknown skill: ${request.params.uri}`);
|
|
47
|
+
return { resultType: 'complete', skill: entry, ...CACHE };
|
|
48
|
+
});
|
|
49
|
+
for (const skill of found) {
|
|
50
|
+
for (const file of skill.files) {
|
|
51
|
+
server.registerResource(file.uri, file.uri, { mimeType: mimeType(file) }, () => ({
|
|
52
|
+
contents: [content(file)],
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function scan(root) {
|
|
58
|
+
let names;
|
|
59
|
+
try {
|
|
60
|
+
names = readdirSync(root).sort();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new Error(`skills: no skills directory at ${root}`);
|
|
64
|
+
}
|
|
65
|
+
const found = names
|
|
66
|
+
.filter((name) => statSync(join(root, name)).isDirectory())
|
|
67
|
+
.filter((name) => statSync(join(root, name, 'SKILL.md'), { throwIfNoEntry: false })?.isFile())
|
|
68
|
+
.map((name) => load(join(root, name), name));
|
|
69
|
+
if (found.length === 0)
|
|
70
|
+
throw new Error(`skills: no <name>/SKILL.md under ${root}`);
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
function load(dir, name) {
|
|
74
|
+
const main = join(dir, 'SKILL.md');
|
|
75
|
+
const frontmatter = parseFrontmatter(readFileSync(main, 'utf8'));
|
|
76
|
+
if (!frontmatter?.description) {
|
|
77
|
+
throw new Error(`skills: ${main} needs frontmatter with a name and a description`);
|
|
78
|
+
}
|
|
79
|
+
if (frontmatter.name !== name) {
|
|
80
|
+
throw new Error(`skills: ${main} is named ${JSON.stringify(frontmatter.name)}; ` +
|
|
81
|
+
`a skill's name must match its directory, ${JSON.stringify(name)}`);
|
|
82
|
+
}
|
|
83
|
+
// SKILL.md first, as the spec's examples list it.
|
|
84
|
+
const paths = [main, ...walk(dir).filter((path) => path !== main).sort()];
|
|
85
|
+
const files = paths.map((path) => {
|
|
86
|
+
const bytes = readFileSync(path);
|
|
87
|
+
return {
|
|
88
|
+
uri: `skill://${name}/${relative(dir, path).split(sep).join('/')}`,
|
|
89
|
+
digest: `sha256:${createHash('sha256').update(bytes).digest('hex')}`,
|
|
90
|
+
size: bytes.length,
|
|
91
|
+
bytes,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
entry: {
|
|
96
|
+
uri: `skill://${name}/SKILL.md`,
|
|
97
|
+
frontmatter,
|
|
98
|
+
resources: files.map(({ uri, digest, size }) => ({ uri, digest, size })),
|
|
99
|
+
},
|
|
100
|
+
files,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function parseFrontmatter(text) {
|
|
104
|
+
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(text);
|
|
105
|
+
const parsed = match ? parse(match[1]) : undefined;
|
|
106
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
107
|
+
? parsed
|
|
108
|
+
: undefined;
|
|
109
|
+
}
|
|
110
|
+
// Regular files only: a symlink could point outside the skill, and serving it
|
|
111
|
+
// would publish whatever it points at.
|
|
112
|
+
function walk(dir) {
|
|
113
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
114
|
+
const path = join(dir, entry.name);
|
|
115
|
+
if (entry.isDirectory())
|
|
116
|
+
return walk(path);
|
|
117
|
+
return entry.isFile() ? [path] : [];
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function mimeType(file) {
|
|
121
|
+
if (!isUtf8(file.bytes))
|
|
122
|
+
return 'application/octet-stream';
|
|
123
|
+
return extname(file.uri) === '.md' ? 'text/markdown' : 'text/plain';
|
|
124
|
+
}
|
|
125
|
+
function content(file) {
|
|
126
|
+
// Text only when it round-trips: a host checks the digest against the bytes
|
|
127
|
+
// it reassembles, and a lossy decode would fail that check.
|
|
128
|
+
return isUtf8(file.bytes)
|
|
129
|
+
? { uri: file.uri, mimeType: mimeType(file), text: file.bytes.toString('utf8') }
|
|
130
|
+
: { uri: file.uri, mimeType: mimeType(file), blob: file.bytes.toString('base64') };
|
|
131
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foro-sh/foro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "TypeScript SDK for Foro",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -48,7 +48,24 @@
|
|
|
48
48
|
"node": ">=18"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
51
52
|
"@types/node": "^26.2.0",
|
|
52
|
-
"typescript": "^7.0.2"
|
|
53
|
+
"typescript": "^7.0.2",
|
|
54
|
+
"zod": "^4.6.5"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
58
|
+
"zod": "^4.0.0"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"yaml": "^2.9.1"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@modelcontextprotocol/sdk": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"zod": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
53
70
|
}
|
|
54
71
|
}
|