@okfit/mcp 0.1.0 → 0.3.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 +64 -3
- package/bin/okfit-mcp.js +3 -4
- package/errors.js +105 -0
- package/index.d.ts +722 -8
- package/index.js +9 -20
- package/internal/projectRoot.js +14 -0
- package/internal/resolveNow.js +26 -0
- package/internal/toolContext.js +77 -0
- package/main.d.ts +20 -0
- package/main.js +45 -0
- package/package.json +13 -3
- package/resources/conceptResource.js +63 -0
- package/resources/indexResource.js +49 -0
- package/schema/ConceptSummary.js +27 -0
- package/schema/tools.js +78 -0
- package/server.js +35 -0
- package/toolkit.js +34 -0
- package/tools/conceptNeighbors.js +87 -0
- package/tools/describeVocabulary.js +73 -0
- package/tools/getConcept.js +93 -0
- package/tools/listConcepts.js +81 -0
- package/tools/staleReport.js +53 -0
- package/tools/validateBundle.js +94 -0
- package/version.js +11 -0
package/index.js
CHANGED
|
@@ -1,21 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
* @public
|
|
11
|
-
*/
|
|
12
|
-
const NOT_IMPLEMENTED_MESSAGE = "okfit-mcp: the MCP server is not implemented yet. Track progress at https://github.com/spencerbeggs/okfit.";
|
|
13
|
-
/**
|
|
14
|
-
* Exit code the stub bin uses.
|
|
15
|
-
*
|
|
16
|
-
* @public
|
|
17
|
-
*/
|
|
18
|
-
const NOT_IMPLEMENTED_EXIT_CODE = 1;
|
|
1
|
+
import { BundleNotFound, ConceptNotFound, ConfigError, InvalidArgument, McpToolError, Remediation, UnknownVocabulary, composeRemediatedMessage, truncateEchoed } from "./errors.js";
|
|
2
|
+
import { ConceptResources } from "./resources/conceptResource.js";
|
|
3
|
+
import { IndexResource } from "./resources/indexResource.js";
|
|
4
|
+
import { ConceptSummary, toConceptSummary } from "./schema/ConceptSummary.js";
|
|
5
|
+
import { ConceptNeighborsSuccess, DescribeVocabularySuccess, GetConceptSuccess, ListConceptsParams, ListConceptsSuccess, Neighbor, StaleReportParams, StaleReportSuccess, ValidateBundleParams } from "./schema/tools.js";
|
|
6
|
+
import { MCP_VERSION } from "./version.js";
|
|
7
|
+
import { OkfitToolkit, ToolsLayer } from "./toolkit.js";
|
|
8
|
+
import { ServerLayer } from "./server.js";
|
|
19
9
|
|
|
20
|
-
|
|
21
|
-
export { NOT_IMPLEMENTED_EXIT_CODE, NOT_IMPLEMENTED_MESSAGE };
|
|
10
|
+
export { BundleNotFound, ConceptNeighborsSuccess, ConceptNotFound, ConceptResources, ConceptSummary, ConfigError, DescribeVocabularySuccess, GetConceptSuccess, IndexResource, InvalidArgument, ListConceptsParams, ListConceptsSuccess, MCP_VERSION, McpToolError, Neighbor, OkfitToolkit, Remediation, ServerLayer, StaleReportParams, StaleReportSuccess, ToolsLayer, UnknownVocabulary, ValidateBundleParams, composeRemediatedMessage, toConceptSummary, truncateEchoed };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/internal/projectRoot.ts
|
|
2
|
+
/**
|
|
3
|
+
* N-21's precedence, exactly: `OKFIT_PROJECT_DIR`, else
|
|
4
|
+
* `CLAUDE_PROJECT_DIR`, else the process's working directory. Pure over
|
|
5
|
+
* its `env` argument so tests never touch `process.env`. No argv flags:
|
|
6
|
+
* `start-mcp.sh` forwards `"$@"` with nothing in it, and the manifest's
|
|
7
|
+
* `cwd` field is unused.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
const resolveMcpProjectRoot = (env) => env["OKFIT_PROJECT_DIR"] ?? env["CLAUDE_PROJECT_DIR"] ?? process.cwd();
|
|
12
|
+
|
|
13
|
+
//#endregion
|
|
14
|
+
export { resolveMcpProjectRoot };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { InvalidArgument, composeRemediatedMessage } from "../errors.js";
|
|
2
|
+
import { DateTime, Effect, Schema } from "effect";
|
|
3
|
+
import { Timestamp } from "@okfit/core";
|
|
4
|
+
|
|
5
|
+
//#region src/internal/resolveNow.ts
|
|
6
|
+
/**
|
|
7
|
+
* An optional ISO-8601 `now` argument, else the Effect clock. Decoding
|
|
8
|
+
* happens here rather than in the parameter schema so a malformed value
|
|
9
|
+
* becomes this contract's `InvalidArgument` — with a remediation hint —
|
|
10
|
+
* instead of Effect's generic protocol-level `InvalidParams` (J-5).
|
|
11
|
+
* `OKFIT_NOW` is deliberately NOT read: it is the CLI's own test hook
|
|
12
|
+
* (F-17), never the server's.
|
|
13
|
+
*
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
const resolveNow = (input) => input === void 0 ? DateTime.now : Schema.decodeUnknownEffect(Timestamp)(input).pipe(Effect.mapError((issue) => {
|
|
17
|
+
const remediation = { hint: "now must be an ISO-8601 instant with an explicit offset, for example 2026-09-06T00:00:00Z." };
|
|
18
|
+
return new InvalidArgument({
|
|
19
|
+
argument: "now",
|
|
20
|
+
message: composeRemediatedMessage(String(issue), remediation),
|
|
21
|
+
remediation
|
|
22
|
+
});
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
export { resolveNow };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { BundleNotFound, ConfigError, composeRemediatedMessage } from "../errors.js";
|
|
2
|
+
import { Effect, Option } from "effect";
|
|
3
|
+
import { Bundle } from "@okfit/core";
|
|
4
|
+
import { provideConfig, resolveProjectConfig } from "@okfit/engine";
|
|
5
|
+
|
|
6
|
+
//#region src/internal/toolContext.ts
|
|
7
|
+
/**
|
|
8
|
+
* The error's `message` when it has one as a string, else `String(error)`.
|
|
9
|
+
* A four-line local copy of `CLI/render/json.ts`'s own helper, which is
|
|
10
|
+
* module-private there and cannot be imported.
|
|
11
|
+
*/
|
|
12
|
+
const messageOf = (error) => {
|
|
13
|
+
if (typeof error === "object" && error !== null && "message" in error) {
|
|
14
|
+
const message = error.message;
|
|
15
|
+
if (typeof message === "string") return message;
|
|
16
|
+
}
|
|
17
|
+
return String(error);
|
|
18
|
+
};
|
|
19
|
+
const CONFIG_HINT = "Check the project's .okfit.toml, okfit.toml, or .config/okfit.toml for a syntax or schema error; remove it to fall back to defaults.";
|
|
20
|
+
/**
|
|
21
|
+
* Config resolution alone, with every failure collapsed to `ConfigError`.
|
|
22
|
+
* `describe_vocabulary` and `validate_bundle` use this; every other tool
|
|
23
|
+
* goes through {@link loadToolContext}.
|
|
24
|
+
*
|
|
25
|
+
* The requirement union (`FileSystem.FileSystem | Path.Path | AppDirs | Xdg`)
|
|
26
|
+
* is inlined here rather than named, so API Extractor never needs a local
|
|
27
|
+
* type alias exported from the package's entry point (K-32-adjacent; a
|
|
28
|
+
* private `ToolServices` alias failed `ae-forgotten-export` at the entry
|
|
29
|
+
* point in this task's own build).
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
const resolveConfigOnly = (projectRoot) => resolveProjectConfig({
|
|
34
|
+
pathArg: Option.none(),
|
|
35
|
+
explicitConfigPath: Option.none(),
|
|
36
|
+
cwd: projectRoot
|
|
37
|
+
}).pipe(provideConfig({
|
|
38
|
+
explicitConfigPath: Option.none(),
|
|
39
|
+
discoveryCwd: projectRoot
|
|
40
|
+
}), Effect.mapError((cause) => {
|
|
41
|
+
const remediation = {
|
|
42
|
+
hint: CONFIG_HINT,
|
|
43
|
+
suggestedTool: "describe_vocabulary"
|
|
44
|
+
};
|
|
45
|
+
return new ConfigError({
|
|
46
|
+
message: composeRemediatedMessage(messageOf(cause), remediation),
|
|
47
|
+
remediation
|
|
48
|
+
});
|
|
49
|
+
}));
|
|
50
|
+
/**
|
|
51
|
+
* Resolve config and load the bundle, fresh, for one tool call (N-9). No
|
|
52
|
+
* cache and no `reload` tool: correct under mid-session edits, trivially
|
|
53
|
+
* testable, and the bundles in scope are small.
|
|
54
|
+
*
|
|
55
|
+
* @internal
|
|
56
|
+
*/
|
|
57
|
+
const loadToolContext = (projectRoot) => Effect.gen(function* () {
|
|
58
|
+
const resolved = yield* resolveConfigOnly(projectRoot);
|
|
59
|
+
const bundle = yield* Bundle.load({ root: resolved.bundleRoot }).pipe(Effect.mapError((cause) => {
|
|
60
|
+
const remediation = { hint: `The bundle root "${resolved.bundleRoot}" does not exist or could not be read; check the config's [bundle].path, or run \`okfit init\`.` };
|
|
61
|
+
return new BundleNotFound({
|
|
62
|
+
root: resolved.bundleRoot,
|
|
63
|
+
message: composeRemediatedMessage(cause.message, remediation),
|
|
64
|
+
remediation
|
|
65
|
+
});
|
|
66
|
+
}));
|
|
67
|
+
return {
|
|
68
|
+
projectRoot,
|
|
69
|
+
bundleRoot: resolved.bundleRoot,
|
|
70
|
+
config: resolved.config,
|
|
71
|
+
profile: resolved.profile,
|
|
72
|
+
bundle
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
//#endregion
|
|
77
|
+
export { loadToolContext, messageOf, resolveConfigOnly };
|
package/main.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/main.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The assembled okfit MCP server program.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Run the okfit MCP server over stdio. Owns the process.
|
|
9
|
+
*
|
|
10
|
+
* This module deliberately carries NO static imports of the server graph:
|
|
11
|
+
* the `uncaughtException` and `unhandledRejection` handlers are registered
|
|
12
|
+
* before `NodeRuntime`, the logger and `ServerLayer` are ever evaluated, so
|
|
13
|
+
* a throw during module evaluation is still reported on stderr rather than
|
|
14
|
+
* crashing silently. Adding a static import here would defeat that.
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
export declare const main: () => Promise<void>;
|
|
19
|
+
//#endregion
|
|
20
|
+
//# sourceMappingURL=main.d.ts.map
|
package/main.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/main.ts
|
|
2
|
+
/**
|
|
3
|
+
* The assembled okfit MCP server program.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
const FATAL_FALLBACK = "okfit-mcp: a fatal error occurred and could not be described.";
|
|
8
|
+
const describe = (error) => {
|
|
9
|
+
try {
|
|
10
|
+
if (error instanceof Error) return error.stack ?? error.message;
|
|
11
|
+
return String(error);
|
|
12
|
+
} catch {
|
|
13
|
+
return FATAL_FALLBACK;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const fatal = (label, error) => {
|
|
17
|
+
process.stderr.write(`okfit-mcp: ${label}: ${describe(error)}\n`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Run the okfit MCP server over stdio. Owns the process.
|
|
22
|
+
*
|
|
23
|
+
* This module deliberately carries NO static imports of the server graph:
|
|
24
|
+
* the `uncaughtException` and `unhandledRejection` handlers are registered
|
|
25
|
+
* before `NodeRuntime`, the logger and `ServerLayer` are ever evaluated, so
|
|
26
|
+
* a throw during module evaluation is still reported on stderr rather than
|
|
27
|
+
* crashing silently. Adding a static import here would defeat that.
|
|
28
|
+
*
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
const main = async () => {
|
|
32
|
+
process.on("uncaughtException", (error) => fatal("uncaught exception", error));
|
|
33
|
+
process.on("unhandledRejection", (reason) => fatal("unhandled rejection", reason));
|
|
34
|
+
const NodeRuntime = await import("@effect/platform-node/NodeRuntime");
|
|
35
|
+
const { OkfitPlatform } = await import("@okfit/engine");
|
|
36
|
+
const { Cause, Exit, Layer, Logger, Runtime } = await import("effect");
|
|
37
|
+
const { resolveMcpProjectRoot } = await import("./internal/projectRoot.js");
|
|
38
|
+
const { ServerLayer } = await import("./server.js");
|
|
39
|
+
const projectRoot = resolveMcpProjectRoot(process.env);
|
|
40
|
+
const program = Layer.launch(ServerLayer(projectRoot).pipe(Layer.provide(OkfitPlatform), Layer.provide(Logger.layer([Logger.consolePretty()])), Layer.provide(Layer.succeed(Logger.LogToStderr, true))));
|
|
41
|
+
NodeRuntime.runMain(program, { teardown: (exit, onExit) => Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) ? onExit(0) : Runtime.defaultTeardown(exit, onExit) });
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
export { main };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@okfit/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Model Context Protocol server for okfit: query and understand Open Knowledge Format (OKF) bundles from an agent.",
|
|
6
6
|
"keywords": [
|
|
@@ -33,14 +33,24 @@
|
|
|
33
33
|
"import": "./index.js",
|
|
34
34
|
"default": "./index.js"
|
|
35
35
|
},
|
|
36
|
+
"./main": {
|
|
37
|
+
"types": "./main.d.ts",
|
|
38
|
+
"import": "./main.js",
|
|
39
|
+
"default": "./main.js"
|
|
40
|
+
},
|
|
36
41
|
"./package.json": "./package.json"
|
|
37
42
|
},
|
|
38
43
|
"bin": {
|
|
39
44
|
"okfit-mcp": "bin/okfit-mcp.js"
|
|
40
45
|
},
|
|
41
46
|
"dependencies": {
|
|
42
|
-
"@
|
|
43
|
-
"
|
|
47
|
+
"@effect/platform-node": "4.0.0-rc.112",
|
|
48
|
+
"@effected/git": "^0.12.0",
|
|
49
|
+
"@effected/xdg": "^0.4.1",
|
|
50
|
+
"@okfit/core": "0.2.0",
|
|
51
|
+
"@okfit/engine": "0.1.0",
|
|
52
|
+
"@okfit/profiles": "0.2.0",
|
|
53
|
+
"effect": "4.0.0-rc.112"
|
|
44
54
|
},
|
|
45
55
|
"engines": {
|
|
46
56
|
"node": ">=24.11.0"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { loadToolContext } from "../internal/toolContext.js";
|
|
2
|
+
import { Effect, FileSystem, Layer, Path } from "effect";
|
|
3
|
+
import { Derive } from "@okfit/core";
|
|
4
|
+
import { McpSchema, McpServer } from "effect/unstable/ai";
|
|
5
|
+
|
|
6
|
+
//#region src/resources/conceptResource.ts
|
|
7
|
+
/**
|
|
8
|
+
* `okf://concept/<id>` — one static resource per concept in the bundle,
|
|
9
|
+
* built once at server start.
|
|
10
|
+
*
|
|
11
|
+
* Amends N-19 and contract §6.1 (Ruling, task C4): a `McpServer.resource`
|
|
12
|
+
* URI **template** routes a `McpSchema.param` through a single path
|
|
13
|
+
* segment only — `FindMyWay`'s parametric matcher stops a param at the
|
|
14
|
+
* next `/` (`.repos/effect/packages/effect/src/unstable/http/FindMyWay/internal/router.ts:355-360`),
|
|
15
|
+
* and every non-root OKF concept id nests under a type directory (D-12),
|
|
16
|
+
* e.g. `metrics/revenue`. A templated `okf://concept/{id}` therefore never
|
|
17
|
+
* routes a real bundle id. Static per-concept resources sidestep the
|
|
18
|
+
* router entirely: each concept gets its own literal `uri`, so there is
|
|
19
|
+
* no segment-spanning to fail.
|
|
20
|
+
*
|
|
21
|
+
* The list is fixed at server start (`loadToolContext` runs once, inside
|
|
22
|
+
* `Layer.unwrap`, not per read): a concept added after boot is not listed
|
|
23
|
+
* until the server restarts, though editing an already-listed concept's
|
|
24
|
+
* file is picked up live, since `content` re-reads the file from disk on
|
|
25
|
+
* every read (same complete text `get_concept`'s `raw` field returns).
|
|
26
|
+
*
|
|
27
|
+
* If the bundle fails to load at boot (bad config, missing bundle root),
|
|
28
|
+
* one line is logged and this layer contributes no resources — the
|
|
29
|
+
* six tools stay usable; only `okf://index` remains, since it is
|
|
30
|
+
* registered independently and re-resolves the bundle on every read.
|
|
31
|
+
*
|
|
32
|
+
* `content` builds the full `ReadResourceResult` itself, `mimeType`
|
|
33
|
+
* included, rather than returning a bare string: verified against source
|
|
34
|
+
* (`resolveResourceContent`, `unstable/ai/McpServer.ts:2324-2343`), a bare
|
|
35
|
+
* string is wrapped as `{ contents: [{ uri, text }] }` with no `mimeType`
|
|
36
|
+
* at all — the declared `mimeType` option only documents the resource in
|
|
37
|
+
* `resources/list`, it is never merged into a `resources/read` response.
|
|
38
|
+
*
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
const ConceptResources = (projectRoot) => Layer.unwrap(Effect.gen(function* () {
|
|
42
|
+
const ctx = yield* loadToolContext(projectRoot);
|
|
43
|
+
const path = yield* Path.Path;
|
|
44
|
+
const fs = yield* FileSystem.FileSystem;
|
|
45
|
+
return [...ctx.bundle.concepts.entries()].map(([id, concept]) => {
|
|
46
|
+
const uri = `okf://concept/${id}`;
|
|
47
|
+
const absolutePath = path.join(ctx.bundleRoot, concept.path);
|
|
48
|
+
return McpServer.resource({
|
|
49
|
+
uri,
|
|
50
|
+
name: id,
|
|
51
|
+
description: Derive.title(concept),
|
|
52
|
+
mimeType: "text/markdown",
|
|
53
|
+
content: fs.readFileString(absolutePath).pipe(Effect.map((text) => ({ contents: [{
|
|
54
|
+
uri,
|
|
55
|
+
mimeType: "text/markdown",
|
|
56
|
+
text
|
|
57
|
+
}] })), Effect.mapError(() => new McpSchema.InternalError({ message: `no readable concept file at ${absolutePath}` })))
|
|
58
|
+
});
|
|
59
|
+
}).reduce((acc, layer) => acc.pipe(Layer.merge(layer)), Layer.empty);
|
|
60
|
+
}).pipe(Effect.tapError((error) => Effect.logError(`okfit-mcp: could not load the bundle to register concept resources: ${error.message}`)), Effect.orElseSucceed(() => Layer.empty)));
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
63
|
+
export { ConceptResources };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { loadToolContext } from "../internal/toolContext.js";
|
|
2
|
+
import { Effect, FileSystem, Path } from "effect";
|
|
3
|
+
import { McpSchema, McpServer } from "effect/unstable/ai";
|
|
4
|
+
|
|
5
|
+
//#region src/resources/indexResource.ts
|
|
6
|
+
/**
|
|
7
|
+
* `okf://index` — the bundle's root `index.md`, read from disk.
|
|
8
|
+
*
|
|
9
|
+
* This reads the file from disk, deliberately (J-7): `IndexDocument`
|
|
10
|
+
* carries only `{ path, dir, okfVersion?, sections }` — no raw-source
|
|
11
|
+
* field at all — so `bundle.indexes.get("")` cannot supply the raw
|
|
12
|
+
* markdown N-19 asks for. Reading the file also means a root `index.md`
|
|
13
|
+
* that failed to parse is still served, which is the more useful
|
|
14
|
+
* behaviour for an agent trying to fix it.
|
|
15
|
+
*
|
|
16
|
+
* A missing `index.md` fails the read rather than returning placeholder
|
|
17
|
+
* text (J-8): a resource that silently returns prose instead of the file
|
|
18
|
+
* it names is indistinguishable, to a model, from a bundle whose index
|
|
19
|
+
* really says that; §6.1 already fails an unknown id through the same
|
|
20
|
+
* channel.
|
|
21
|
+
*
|
|
22
|
+
* `content` builds the full `ReadResourceResult` itself, `mimeType`
|
|
23
|
+
* included, rather than returning a bare string: verified against source
|
|
24
|
+
* (`resolveResourceContent`, `unstable/ai/McpServer.ts:2324-2343`), a bare
|
|
25
|
+
* string is wrapped as `{ contents: [{ uri, text }] }` with no `mimeType`
|
|
26
|
+
* at all — the declared `mimeType` option only documents the resource in
|
|
27
|
+
* `resources/list`, it is never merged into a `resources/read` response.
|
|
28
|
+
*
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
const IndexResource = (projectRoot) => McpServer.resource({
|
|
32
|
+
uri: "okf://index",
|
|
33
|
+
name: "OKF bundle index",
|
|
34
|
+
description: "The bundle's root index.md — the entry point for orienting on this OKF bundle.",
|
|
35
|
+
mimeType: "text/markdown",
|
|
36
|
+
content: Effect.gen(function* () {
|
|
37
|
+
const ctx = yield* loadToolContext(projectRoot);
|
|
38
|
+
const fs = yield* FileSystem.FileSystem;
|
|
39
|
+
const indexPath = (yield* Path.Path).join(ctx.bundleRoot, "index.md");
|
|
40
|
+
return { contents: [{
|
|
41
|
+
uri: "okf://index",
|
|
42
|
+
mimeType: "text/markdown",
|
|
43
|
+
text: yield* fs.readFileString(indexPath).pipe(Effect.mapError(() => new McpSchema.InternalError({ message: `no readable index.md at ${indexPath}` })))
|
|
44
|
+
}] };
|
|
45
|
+
})
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
//#endregion
|
|
49
|
+
export { IndexResource };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { Derive, Status } from "@okfit/core";
|
|
3
|
+
|
|
4
|
+
//#region src/schema/ConceptSummary.ts
|
|
5
|
+
/** The seven-field shape every list-like tool result carries (N-16). @public */
|
|
6
|
+
const ConceptSummary = Schema.Struct({
|
|
7
|
+
id: Schema.String,
|
|
8
|
+
type: Schema.String,
|
|
9
|
+
title: Schema.String,
|
|
10
|
+
description: Schema.NullOr(Schema.String),
|
|
11
|
+
status: Status,
|
|
12
|
+
tags: Schema.Array(Schema.String),
|
|
13
|
+
path: Schema.String
|
|
14
|
+
});
|
|
15
|
+
/** Project one loaded concept into its summary. @public */
|
|
16
|
+
const toConceptSummary = (concept) => ({
|
|
17
|
+
id: concept.id,
|
|
18
|
+
type: concept.frontmatter.type,
|
|
19
|
+
title: Derive.title(concept),
|
|
20
|
+
description: concept.frontmatter.description ?? null,
|
|
21
|
+
status: Derive.status(concept.frontmatter),
|
|
22
|
+
tags: [...concept.frontmatter.tags ?? []],
|
|
23
|
+
path: concept.path
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
export { ConceptSummary, toConceptSummary };
|
package/schema/tools.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { ConceptSummary } from "./ConceptSummary.js";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
import { GraphNodeKind, Status } from "@okfit/core";
|
|
4
|
+
import { ContextTag, ContextType } from "@okfit/engine";
|
|
5
|
+
|
|
6
|
+
//#region src/schema/tools.ts
|
|
7
|
+
/** `describe_vocabulary`'s result (§5.2). @public */
|
|
8
|
+
const DescribeVocabularySuccess = Schema.Struct({
|
|
9
|
+
project_root: Schema.String,
|
|
10
|
+
bundle_root: Schema.String,
|
|
11
|
+
config_path: Schema.NullOr(Schema.String),
|
|
12
|
+
profile: Schema.NullOr(Schema.String),
|
|
13
|
+
profile_requested: Schema.NullOr(Schema.String),
|
|
14
|
+
agent: Schema.NullOr(Schema.String),
|
|
15
|
+
types: Schema.Array(ContextType),
|
|
16
|
+
tags: Schema.Array(ContextTag)
|
|
17
|
+
});
|
|
18
|
+
/** `list_concepts`' arguments (§5.3). @public */
|
|
19
|
+
const ListConceptsParams = Schema.Struct({
|
|
20
|
+
type: Schema.optionalKey(Schema.String),
|
|
21
|
+
tags: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
22
|
+
status: Schema.optionalKey(Status),
|
|
23
|
+
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({
|
|
24
|
+
minimum: 1,
|
|
25
|
+
maximum: 1e3
|
|
26
|
+
}))),
|
|
27
|
+
offset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)))
|
|
28
|
+
});
|
|
29
|
+
/** `list_concepts`' result: the page, plus the match count before paging. @public */
|
|
30
|
+
const ListConceptsSuccess = Schema.Struct({
|
|
31
|
+
items: Schema.Array(ConceptSummary),
|
|
32
|
+
total: Schema.Int
|
|
33
|
+
});
|
|
34
|
+
/** `get_concept`'s result (§5.4). @public */
|
|
35
|
+
const GetConceptSuccess = Schema.Struct({
|
|
36
|
+
id: Schema.String,
|
|
37
|
+
type: Schema.String,
|
|
38
|
+
title: Schema.String,
|
|
39
|
+
description: Schema.NullOr(Schema.String),
|
|
40
|
+
status: Status,
|
|
41
|
+
tags: Schema.Array(Schema.String),
|
|
42
|
+
path: Schema.String,
|
|
43
|
+
frontmatter: Schema.Record(Schema.String, Schema.Unknown),
|
|
44
|
+
raw: Schema.String,
|
|
45
|
+
links: Schema.Array(Schema.Struct({
|
|
46
|
+
to: Schema.String,
|
|
47
|
+
kind: GraphNodeKind,
|
|
48
|
+
source: Schema.Literals(["body", "frontmatter"]),
|
|
49
|
+
field: Schema.optionalKey(Schema.String)
|
|
50
|
+
}))
|
|
51
|
+
});
|
|
52
|
+
/** One graph neighbour: flat, with a nullable summary (J-4). @public */
|
|
53
|
+
const Neighbor = Schema.Struct({
|
|
54
|
+
id: Schema.String,
|
|
55
|
+
kind: GraphNodeKind,
|
|
56
|
+
summary: Schema.NullOr(ConceptSummary)
|
|
57
|
+
});
|
|
58
|
+
/** `concept_neighbors`' result (§5.5). @public */
|
|
59
|
+
const ConceptNeighborsSuccess = Schema.Struct({
|
|
60
|
+
outgoing: Schema.Array(Neighbor),
|
|
61
|
+
incoming: Schema.Array(Neighbor)
|
|
62
|
+
});
|
|
63
|
+
/** `stale_report`'s arguments: an optional ISO instant, decoded in the handler (J-5). @public */
|
|
64
|
+
const StaleReportParams = Schema.Struct({ now: Schema.optionalKey(Schema.String) });
|
|
65
|
+
/** `stale_report`'s result (§5.6). @public */
|
|
66
|
+
const StaleReportSuccess = Schema.Struct({
|
|
67
|
+
as_of: Schema.String,
|
|
68
|
+
items: Schema.Array(Schema.Struct({
|
|
69
|
+
summary: ConceptSummary,
|
|
70
|
+
stale_after: Schema.String,
|
|
71
|
+
days_past: Schema.Int
|
|
72
|
+
}))
|
|
73
|
+
});
|
|
74
|
+
/** `validate_bundle`'s arguments: the same shape and decode path as stale_report. @public */
|
|
75
|
+
const ValidateBundleParams = Schema.Struct({ now: Schema.optionalKey(Schema.String) });
|
|
76
|
+
|
|
77
|
+
//#endregion
|
|
78
|
+
export { ConceptNeighborsSuccess, DescribeVocabularySuccess, GetConceptSuccess, ListConceptsParams, ListConceptsSuccess, Neighbor, StaleReportParams, StaleReportSuccess, ValidateBundleParams };
|
package/server.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ConceptResources } from "./resources/conceptResource.js";
|
|
2
|
+
import { IndexResource } from "./resources/indexResource.js";
|
|
3
|
+
import { MCP_VERSION } from "./version.js";
|
|
4
|
+
import { OkfitToolkit, ToolsLayer } from "./toolkit.js";
|
|
5
|
+
import { Layer } from "effect";
|
|
6
|
+
import { McpProtocol, McpServer } from "effect/unstable/ai";
|
|
7
|
+
import { Git } from "@effected/git";
|
|
8
|
+
import { GitHistory } from "@okfit/profiles";
|
|
9
|
+
|
|
10
|
+
//#region src/server.ts
|
|
11
|
+
/**
|
|
12
|
+
* The whole server as one layer: the toolkit, one static resource per
|
|
13
|
+
* concept (`okf://concept/<id>`, built once at boot — see
|
|
14
|
+
* {@link ConceptResources}), and `okf://index` (re-read from disk on every
|
|
15
|
+
* call), over `McpServer.layerStdio`.
|
|
16
|
+
*
|
|
17
|
+
* `protocols` ships BOTH adapters, newest first (N-2). Array order is
|
|
18
|
+
* load-bearing: the protocol registry falls back to `protocols[0]` for an
|
|
19
|
+
* unrecognised client version, so a 2026-07-28 client is answered with
|
|
20
|
+
* 2025-11-25. Never reduce this to one entry.
|
|
21
|
+
*
|
|
22
|
+
* `Cause.IllegalArgumentError` in `layerStdio`'s signature is left
|
|
23
|
+
* unhandled: `protocols` is a static two-element literal, so it is an
|
|
24
|
+
* implementer-time defect, not a runtime condition.
|
|
25
|
+
*
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
const ServerLayer = (projectRoot) => Layer.mergeAll(McpServer.toolkit(OkfitToolkit).pipe(Layer.provideMerge(ToolsLayer(projectRoot))), ConceptResources(projectRoot), IndexResource(projectRoot)).pipe(Layer.provide(Layer.mergeAll(Git.layer, GitHistory.layer)), Layer.provide(McpServer.layerStdio({
|
|
29
|
+
name: "okfit",
|
|
30
|
+
version: MCP_VERSION,
|
|
31
|
+
protocols: [McpProtocol.v2025_11_25, McpProtocol.v2025_06_18]
|
|
32
|
+
})), Layer.orDie);
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
export { ServerLayer };
|
package/toolkit.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { conceptNeighbors, handleConceptNeighbors } from "./tools/conceptNeighbors.js";
|
|
2
|
+
import { describeVocabulary, handleDescribeVocabulary } from "./tools/describeVocabulary.js";
|
|
3
|
+
import { getConcept, handleGetConcept } from "./tools/getConcept.js";
|
|
4
|
+
import { handleListConcepts, listConcepts } from "./tools/listConcepts.js";
|
|
5
|
+
import { handleStaleReport, staleReport } from "./tools/staleReport.js";
|
|
6
|
+
import { handleValidateBundle, validateBundle } from "./tools/validateBundle.js";
|
|
7
|
+
import { Toolkit } from "effect/unstable/ai";
|
|
8
|
+
|
|
9
|
+
//#region src/toolkit.ts
|
|
10
|
+
/**
|
|
11
|
+
* The six read-only tools (N-10). Tasks C1, C2 and C3 each add two; the
|
|
12
|
+
* order here is the tool set's own order and is what
|
|
13
|
+
* `agents/okf-docs.md`'s `tools:` allowlist mirrors.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
const OkfitToolkit = Toolkit.make(describeVocabulary, listConcepts, getConcept, conceptNeighbors, staleReport, validateBundle);
|
|
18
|
+
/**
|
|
19
|
+
* The handler layer. `projectRoot` is closed over from the bin (N-21);
|
|
20
|
+
* the bundle itself reloads on every call inside each handler (N-9).
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
const ToolsLayer = (projectRoot) => OkfitToolkit.toLayer({
|
|
25
|
+
describe_vocabulary: () => handleDescribeVocabulary(projectRoot),
|
|
26
|
+
list_concepts: (params) => handleListConcepts(projectRoot, params),
|
|
27
|
+
get_concept: (params) => handleGetConcept(projectRoot, params),
|
|
28
|
+
concept_neighbors: (params) => handleConceptNeighbors(projectRoot, params),
|
|
29
|
+
stale_report: (params) => handleStaleReport(projectRoot, params),
|
|
30
|
+
validate_bundle: (params) => handleValidateBundle(projectRoot, params)
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { OkfitToolkit, ToolsLayer };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ConceptNotFound, InvalidArgument, McpToolError, composeRemediatedMessage, truncateEchoed } from "../errors.js";
|
|
2
|
+
import { loadToolContext } from "../internal/toolContext.js";
|
|
3
|
+
import { toConceptSummary } from "../schema/ConceptSummary.js";
|
|
4
|
+
import { ConceptNeighborsSuccess } from "../schema/tools.js";
|
|
5
|
+
import { Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
6
|
+
import { ConceptId, Graph } from "@okfit/core";
|
|
7
|
+
import { Tool } from "effect/unstable/ai";
|
|
8
|
+
import { AppDirs, Xdg } from "@effected/xdg";
|
|
9
|
+
|
|
10
|
+
//#region src/tools/conceptNeighbors.ts
|
|
11
|
+
const DESCRIPTION = "Returns the graph neighbours of one concept: everything it links to (outgoing) and everything that links to it (incoming), each with the node kind and, for a concept target, its full summary. Use it after get_concept to walk the bundle's link graph one hop at a time without loading every concept.";
|
|
12
|
+
/**
|
|
13
|
+
* `dependencies` mirrors the other tools' (Task B1's Deviation 1): without it
|
|
14
|
+
* `Tool.HandlerServices` infers `never`, failing the handler record against
|
|
15
|
+
* `HandlersFrom` when passed to `OkfitToolkit.toLayer`.
|
|
16
|
+
*
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
const conceptNeighbors = Tool.make("concept_neighbors", {
|
|
20
|
+
description: DESCRIPTION,
|
|
21
|
+
parameters: Schema.Struct({ id: Schema.String }),
|
|
22
|
+
success: ConceptNeighborsSuccess,
|
|
23
|
+
failure: McpToolError,
|
|
24
|
+
dependencies: [
|
|
25
|
+
FileSystem.FileSystem,
|
|
26
|
+
Path.Path,
|
|
27
|
+
AppDirs,
|
|
28
|
+
Xdg
|
|
29
|
+
]
|
|
30
|
+
}).annotate(Tool.Title, "Concept graph neighbors").annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true).annotate(Tool.OpenWorld, false);
|
|
31
|
+
/**
|
|
32
|
+
* Deviation from the brief's literal snippet, matching the ruling binding
|
|
33
|
+
* every C task (progress.md): `message` is composed through
|
|
34
|
+
* {@link composeRemediatedMessage} at construction, since a declared typed
|
|
35
|
+
* failure under `failureMode: "error"` never reaches the wire with
|
|
36
|
+
* `structuredContent` — only `error.message` does.
|
|
37
|
+
*
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
const handleConceptNeighbors = (projectRoot, params) => Effect.gen(function* () {
|
|
41
|
+
const ctx = yield* loadToolContext(projectRoot);
|
|
42
|
+
const normalized = ConceptId.normalize(params.id);
|
|
43
|
+
if (Option.isNone(normalized)) {
|
|
44
|
+
const remediation = {
|
|
45
|
+
hint: "Pass a bundle-relative concept id such as decisions/cli-exit-codes.",
|
|
46
|
+
suggestedTool: "list_concepts"
|
|
47
|
+
};
|
|
48
|
+
return yield* Effect.fail(new InvalidArgument({
|
|
49
|
+
argument: "id",
|
|
50
|
+
message: composeRemediatedMessage("id must not be empty", remediation),
|
|
51
|
+
remediation
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
const id = normalized.value;
|
|
55
|
+
if (!ctx.bundle.concepts.has(id)) {
|
|
56
|
+
const remediation = {
|
|
57
|
+
hint: "Call list_concepts to see the ids this bundle contains.",
|
|
58
|
+
suggestedTool: "list_concepts"
|
|
59
|
+
};
|
|
60
|
+
return yield* Effect.fail(new ConceptNotFound({
|
|
61
|
+
id: params.id,
|
|
62
|
+
message: composeRemediatedMessage(`no concept "${truncateEchoed(params.id)}" in this bundle`, remediation),
|
|
63
|
+
remediation
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
const graph = Graph.fromBundle(ctx.bundle);
|
|
67
|
+
const project = (node) => {
|
|
68
|
+
if (node.kind !== "concept") return {
|
|
69
|
+
id: node.id,
|
|
70
|
+
kind: node.kind,
|
|
71
|
+
summary: null
|
|
72
|
+
};
|
|
73
|
+
const concept = ctx.bundle.concepts.get(node.id);
|
|
74
|
+
return {
|
|
75
|
+
id: node.id,
|
|
76
|
+
kind: node.kind,
|
|
77
|
+
summary: concept === void 0 ? null : toConceptSummary(concept)
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
outgoing: graph.successors(id).map(project),
|
|
82
|
+
incoming: graph.predecessors(id).map(project)
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
//#endregion
|
|
87
|
+
export { conceptNeighbors, handleConceptNeighbors };
|