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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,17 @@
- Add composable graph filters for concept type, path prefix, and edge
resolution.
- Version and document the graph JSON schema.
- Add `okf mcp`, a read-only Model Context Protocol server over stdio with the
- Add `okf mcp`, a Model Context Protocol server over stdio with the
`list-concepts`, `lookup-concept`, `query-graph`, and `validate` tools. Its
`validate` tool returns the same Report and Verdict as the command line, and
`query-graph` takes the graph filter vocabulary as its input schema.
- Add the `create-concept` and `update-concept` MCP write tools, thin adapters
over `OkfBundleChangeApplier`: one call writes the concept and maintains the
`index.md` and `log.md` entries atomically. A change the registered rules
reject is refused with the Report the command line prints for the same
state and leaves no file changed, while input that describes no bundle state
is a plain tool error. Updates manage `type`, `title`, `description`,
`tags`, and `body`, and retain every other frontmatter field.
- Breaking: `okf validate --output json` replaces the `valid`, `error_count`,
`warning_count`, and `diagnostics` fields with the Report projection — a
`findings` array whose entries carry `id`, `severity`, `message`, and
Expand Down
46 changes: 37 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ implementation and is not affiliated with or endorsed by Google.
- Apply validated change sets that write concept, index, and log atomically.
- Parse and emit `index.md` and `log.md` entries through one shared model.
- Export bundle graphs as JSON, DOT, or Mermaid.
- Serve a read-only Model Context Protocol surface for coding agents.
- Serve a Model Context Protocol read/write surface for coding agents.
- Use the APIs without `dart:io`, or import `okf_io.dart` for filesystem
operations.

Expand Down Expand Up @@ -105,25 +105,52 @@ action runs on Linux and macOS runners.

## MCP server

`okf mcp <bundle>` serves a read-only Model Context Protocol surface over
stdio, so a coding agent can navigate and check a bundle without raw file
reads. While the server runs, standard output carries JSON-RPC alone and
every diagnostic goes to standard error. Each call re-reads the bundle, so an
agent that edits files between calls never sees a stale answer.
`okf mcp <bundle>` serves a Model Context Protocol surface over stdio, so a
coding agent can navigate, check, and edit a bundle without raw file reads.
While the server runs, standard output carries JSON-RPC alone and every
diagnostic goes to standard error. Each call re-reads the bundle, so an agent
that edits files between calls never sees a stale answer.

| Tool | Arguments | Returns |
| --- | --- | --- |
| `list-concepts` | none | Every concept with its type, title, status, and trust tier. |
| `lookup-concept` | `id` | One concept, including its canonical Markdown. |
| `query-graph` | `OkfGraphQuery.jsonSchema` | The versioned graph JSON that `okf graph --output json` emits. |
| `validate` | `strict` | The Report `okf validate --output json` emits, plus the Verdict's `exit_code`. |
| `create-concept` | `id`, `type`, `title`, `description`, `tags`, `body` | The bundle-relative paths the write committed. |
| `update-concept` | `id`, `type`, `title`, `description`, `tags`, `body` | The bundle-relative paths the write committed. |

`validate` returns the same Report as the command line for the same bundle
and inputs — the same finding IDs, locations, and severities — and `strict`
is the `--warnings-as-errors` flag, so an agent can
reproduce the CI gate's judgment before pushing. Arguments are validated
against each tool's schema; rejected arguments come back as a tool error.

### Writes

`create-concept` and `update-concept` write through `OkfBundleChangeApplier`,
so one call prepares the concept document and its `index.md` and `log.md`
entries, commits them under the shared bundle lock, and rolls them back
together if an ordinary filesystem write fails. `id` is the bundle-relative
concept ID without the `.md` suffix; `type` is required when creating.

`type`, `title`, `description`, `tags`, and `body` are the fields the verbs
manage. An update overlays only the arguments it is given and retains every
other field — `resource`, `verification`, `sources`, and anything else the
document carries keep their values and their order.

A write is judged before it reaches disk, against the same rules
`okf validate` runs. Two outcomes are distinguished:

- A change the rules reject is **refused**: the call fails with structured
content carrying the Report — the same finding IDs the command line prints
for that state — and not one file is changed. Only Spec errors refuse a
write; an advisory-only candidate remains conformant and can commit.
- Input that describes no bundle state is a plain **tool error**, with a
message and no Report: a malformed argument, an ID that is not
bundle-relative or that would occupy a reserved `index.md` or `log.md` path,
creating a concept that already exists, or updating one that does not.

Register the server with an MCP client by pointing it at the executable:

```json
Expand Down Expand Up @@ -248,9 +275,10 @@ and Markdown content, but YAML comments, anchors, scalar quoting, and
whitespace style are not retained.

Filesystem link checks assume a quiescent bundle rather than a directory tree
being concurrently replaced by an adversarial process. Multi-file writes are
performed independently and do not preserve platform-specific ACLs or extended
attributes.
being concurrently replaced by an adversarial process. Prepared multi-file
writes are rollback-backed, not crash-atomic: destination files are replaced
independently, so a process or power failure can interrupt the transaction.
Writes do not preserve platform-specific ACLs or extended attributes.

## Scope

Expand Down
2 changes: 1 addition & 1 deletion lib/okf_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ export 'okf.dart';
export 'src/io/bundle_change_applier.dart';
export 'src/io/bundle_loader.dart';
export 'src/io/bundle_writer.dart' hide OkfBundleWriteTransaction;
export 'src/mcp/read_server.dart' show OkfMcpServer;
export 'src/mcp/server.dart' show OkfMcpServer;
4 changes: 2 additions & 2 deletions lib/src/bundle_change_overlay.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final class OkfBundleChangeOverlay {
final documents = LinkedHashMap<OkfConceptId, OkfDocument>.of(
base.concepts,
);
final logs = Map<String, String>.of(base.logFiles);
final files = <String, String>{};
final logEntries = <OkfLogEntry>[];
final affectedIndexes = <String>{};
Expand All @@ -63,10 +64,9 @@ final class OkfBundleChangeOverlay {
}
}

final logs = Map<String, String>.of(base.logFiles);
final log = logEntries.isEmpty
? null
: _rewrittenLog(base.logFiles[_rootLogPath], logEntries);
: _rewrittenLog(logs[_rootLogPath], logEntries);
if (log != null) {
logs[_rootLogPath] = log;
files[_rootLogPath] = log;
Expand Down
4 changes: 2 additions & 2 deletions lib/src/cli.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import 'graph.dart';
import 'index_generator.dart';
import 'io/bundle_loader.dart';
import 'io/bundle_writer.dart';
import 'mcp/read_server.dart';
import 'mcp/server.dart';
import 'spec_rules/load_findings.dart';
import 'validator.dart';
import 'version.dart';
Expand Down Expand Up @@ -355,7 +355,7 @@ Commands:
format Canonically format Markdown documents
index Generate deterministic bundle indexes
graph Render the bundle relationship graph
mcp Serve the read tool surface over MCP stdio
mcp Serve the OKF tool surface over MCP stdio

Global options:
${_parser.usage}
Expand Down
174 changes: 157 additions & 17 deletions lib/src/mcp/read_server.dart → lib/src/mcp/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,38 @@ import 'dart:io';

import 'package:mcp_dart/mcp_dart.dart';

import '../bundle_change_set.dart';
import '../concept_id.dart';
import '../document.dart';
import '../finding.dart';
import '../graph.dart';
import '../io/bundle_change_applier.dart';
import '../io/bundle_loader.dart';
import '../version.dart';

/// The read-only OKF tool surface served over the Model Context Protocol.
/// The OKF tool surface served over the Model Context Protocol.
///
/// Every tool re-reads the bundle from disk, so an agent that edits files
/// between calls never observes a stale answer.
/// between calls never observes a stale answer. The write verbs are adapters
/// over [OkfBundleChangeApplier]: they translate tool arguments into a change
/// description and return its result, so validation, index and log
/// maintenance, and atomicity have one owner.
final class OkfMcpServer {
/// Creates a server that answers questions about the bundle at [rootPath].
/// Creates a server that reads and writes the bundle at [rootPath].
OkfMcpServer({
required this.rootPath,
OkfBundleLoader loader = const OkfBundleLoader(),
}) : _loader = loader;
}) : _loader = loader,
_applier = const OkfBundleChangeApplier();

/// The bundle root every tool reads.
final String rootPath;

final OkfBundleLoader _loader;

/// Owns preparation and exact prepared commits for every write tool.
final OkfBundleChangeApplier _applier;

/// Serves the tool surface over stdio until the client disconnects.
///
/// Standard output carries JSON-RPC alone, so every diagnostic goes to
Expand Down Expand Up @@ -59,7 +68,12 @@ final class OkfMcpServer {
capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()),
),
);
_registerReadTools(server);
_registerWriteTools(server);
return server;
}

void _registerReadTools(McpServer server) {
server.registerTool(
'list-concepts',
description: 'List every concept in the bundle with its metadata.',
Expand All @@ -82,12 +96,7 @@ final class OkfMcpServer {
'lookup-concept',
description: 'Read one concept in its canonical Markdown form.',
inputSchema: JsonSchema.object(
properties: <String, JsonSchema>{
'id': JsonSchema.string(
minLength: 1,
description: 'Bundle-relative concept ID, without the .md suffix.',
),
},
properties: <String, JsonSchema>{'id': _conceptIdSchema},
required: const <String>['id'],
additionalProperties: false,
),
Expand Down Expand Up @@ -144,15 +153,59 @@ final class OkfMcpServer {
});
}),
);
}

return server;
void _registerWriteTools(McpServer server) {
server.registerTool(
'create-concept',
description: 'Create a concept, maintaining the index and log with it.',
inputSchema: _writeSchema(const <String>['id', 'type']),
annotations: const ToolAnnotations(
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
),
callback: (arguments, extra) => _write(
() => OkfCreateConceptChange(
id: OkfConceptId(arguments['id']! as String),
document: OkfDocument(
frontmatter: _managedFrontmatter(arguments),
body: arguments['body'] as String? ?? '',
),
),
),
);

server.registerTool(
'update-concept',
description: 'Update the managed fields of an existing concept.',
inputSchema: _writeSchema(const <String>['id']),
annotations: const ToolAnnotations(
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
),
callback: (arguments, extra) => _write(
() {
final frontmatter = _managedFrontmatter(arguments);
final body = arguments['body'] as String?;
if (frontmatter.isEmpty && body == null) {
throw OkfBundleChangeException(
'Update for ${arguments['id']} does not change a managed field.',
);
}
return OkfUpdateConceptChange(
id: OkfConceptId(arguments['id']! as String),
frontmatterChanges: frontmatter,
body: body,
);
},
),
);
}

/// Answers one tool call from a freshly loaded bundle.
///
/// Every failure below the protocol — an unreadable root, a rejected
/// argument, a missing concept — becomes a tool error, so a bad request
/// never ends the session.
Future<CallToolResult> _readComplete(
CallToolResult Function(OkfBundleLoadResult) answer,
) =>
Expand All @@ -168,9 +221,47 @@ final class OkfMcpServer {

Future<CallToolResult> _readInspection(
CallToolResult Function(OkfBundleLoadResult) answer,
) =>
_guard(() async => answer(await _loader.inspect(rootPath)));

/// A Spec-invalid candidate comes back as a refusal carrying the report —
/// the finding IDs `okf validate` prints for the same state — and the bundle
/// is left exactly as it was.
///
/// [describe] is a callback rather than a change so that translating the
/// tool arguments happens inside the guard below, keeping a rejected
/// argument on this server's tool-error path.
Future<CallToolResult> _write(OkfBundleChange Function() describe) =>
_guard(() async {
final application = await _applier.apply(
rootPath,
OkfBundleChangeSet(<OkfBundleChange>[describe()]),
);
return switch (application) {
OkfBundleApplied(result: final result) => _payload(
<String, Object?>{'changed_paths': result.changedPaths},
),
OkfBundleApplicationRefused(validation: final validation) => _error(
'The change was refused; the bundle is unchanged.',
report: validation.report,
),
};
});

/// Runs one tool call, turning every failure below the protocol into a tool
/// error so a bad request never ends the session.
///
/// This is the malformed-input tier that the static input schemas do not
/// already cover: an unreadable root, a rejected concept ID, or a change
/// that describes no bundle state at all. None of them carries a report,
/// which is what separates them from a refusal.
Future<CallToolResult> _guard(
Future<CallToolResult> Function() answer,
) async {
try {
return answer(await _loader.inspect(rootPath));
return await answer();
} on OkfBundleChangeException catch (error) {
return _error(error.message);
} on FormatException catch (error) {
return _error(error.message);
} on Exception catch (error) {
Expand All @@ -186,6 +277,55 @@ const ToolAnnotations _readOnlyAnnotations = ToolAnnotations(
openWorldHint: false,
);

/// The argument every tool that names a single concept takes, declared once so
/// the read and write verbs cannot describe the same ID differently.
final JsonSchema _conceptIdSchema = JsonSchema.string(
minLength: 1,
description: 'Bundle-relative concept ID, without the .md suffix.',
);

/// The parameter shape both write verbs take.
///
/// The properties beyond `id` are the concept fields the verbs manage;
/// everything else a document carries belongs to whoever wrote it. This is
/// also where the malformed-input tier is decided: an argument of the wrong
/// shape is rejected here, before any change is described.
JsonObject _writeSchema(List<String> requiredProperties) => JsonSchema.object(
properties: <String, JsonSchema>{
'id': _conceptIdSchema,
'type': JsonSchema.string(
minLength: 1,
description: 'OKF concept type, such as Reference, Metric, or Note.',
),
'title': JsonSchema.string(minLength: 1, description: 'Display name.'),
'description': JsonSchema.string(description: 'One-line summary.'),
'tags': JsonSchema.array(
items: JsonSchema.string(minLength: 1),
description: 'Cross-cutting category tags.',
uniqueItems: true,
),
'body': JsonSchema.string(
description: 'Markdown body below the frontmatter.',
),
},
required: requiredProperties,
additionalProperties: false,
);

/// The managed frontmatter fields [arguments] carries.
///
/// An absent field is left out rather than nulled, so an update never clears
/// what the caller did not mention.
Map<String, Object?> _managedFrontmatter(Map<String, Object?> arguments) =>
<String, Object?>{
if (arguments['type'] case final String type) 'type': type,
if (arguments['title'] case final String title) 'title': title,
if (arguments['description'] case final String description)
'description': description,
if (arguments['tags'] case final List<Object?> tags)
'tags': tags.cast<String>(),
};

Map<String, Object?> _conceptSummary(OkfConceptId id, OkfDocument document) {
final type = document.type;
final title = document.title;
Expand Down
Loading