← Files FievelARCHIVED FILE
runtime/scripts/mcp_local.py
11.1 KB · Nov 6, 2026 · 00:15 UTC
"""Fievel's local MCP server: structured tools over the Fievel CLI, run on the user's computer.
Speaks MCP over stdio (newline-delimited JSON-RPC). Every path is resolved inside the
workspace (FIEVEL_WORKSPACE, else the current directory); outputs are always new files.
No network access and no model calls.
"""
import json
import os
from pathlib import Path
import platform
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from scripts.mcp_protocol import Server, Tool # noqa: E402
from scripts.mcp_tools import VERSION, knowledge_tools # noqa: E402
WORKSPACE = Path(os.environ.get('FIEVEL_WORKSPACE') or os.getcwd()).resolve()
SETUP = ('The jsonschema package is missing. With the user\'s permission, create .fievel-venv in the '
'workspace and install the plugin\'s runtime/requirements.txt with it, then call this tool again.')
def _use_workspace_environment():
"""Let a server started with the system Python find jsonschema in the workspace's .fievel-venv."""
venv = WORKSPACE / '.fievel-venv'
for site in [venv / 'Lib' / 'site-packages', *sorted(venv.glob('lib/python3*/site-packages'))]:
if site.is_dir() and str(site) not in sys.path:
sys.path.append(str(site))
def core():
_use_workspace_environment()
try:
from scripts import fievel
except SystemExit: # fievel.py exits when jsonschema cannot be imported.
raise ValueError(SETUP) from None
return fievel
def inside(path, must_exist=False):
"""Resolve a path within the workspace; reject anything that escapes it."""
if not isinstance(path, str) or not path.strip():
raise ValueError('Provide a path inside the workspace.')
resolved = (WORKSPACE / path).resolve()
if not resolved.is_relative_to(WORKSPACE):
raise ValueError(f'Path is outside the workspace ({WORKSPACE}): {path}')
if must_exist and not resolved.exists():
raise FileNotFoundError(f'Not found in the workspace: {path}')
return resolved
def optional(path):
return inside(path, must_exist=True) if path else None
def setup_status():
try:
core()
ready = True
except ValueError:
ready = False
return {'ready': ready, 'python': platform.python_version(), 'workspace': str(WORKSPACE),
'runtime': str(ROOT), 'next_step': None if ready else SETUP}
def init(folder, example=False):
target = core().initialize(inside(folder), example)
return {'created': str(target), 'example': example,
'note': 'Draft templates only; nothing is approved. Save progress after each meaningful step.'}
def validate(folder, require_coverage=False):
f = core()
data = f.validate(inside(folder, must_exist=True), require_coverage=require_coverage)
return {'valid': True, 'uncovered_rules': f.coverage_gaps(data),
'note': 'Structure and links are valid. This is not content approval.'}
def status(folder):
f = core()
data = f.validate(inside(folder, must_exist=True))
progress = data['progress']
return {'path': progress['path'], 'explanations': progress['explanations'],
'stages': [{'stage': i, 'name': name, 'status': progress['stages'][str(i)]}
for i, name in enumerate(f.STAGES, 1)],
'decisions': progress.get('decisions', []), 'open_questions': progress.get('open_questions', []),
'next_action': progress['next_action'], 'uncovered_rules': f.coverage_gaps(data)}
def check(folder, responses_file, out):
f = core()
result = f.evaluate(inside(folder, must_exist=True), f.read(inside(responses_file, must_exist=True)))
f.write_new(inside(out), result)
failures = [{k: row[k] for k in ('scenario_id', 'check_id', 'requirement_id', 'question')}
for row in result['results'] if row['status'] == 'fail']
return {'saved': out, 'summary': result['summary'], 'failures': failures,
'note': 'not_run includes human questions and situations without saved responses. '
'Literal checks are not an overall quality verdict.'}
def review(folder, responses_file, out, evidence_config=''):
f = core()
responses = f.read(inside(responses_file, must_exist=True))
if evidence_config:
from scripts.review_evidence import prepare_review
result = prepare_review(inside(folder, must_exist=True), responses, f.read(optional(evidence_config)))
else:
result = f.review_sheet(inside(folder, must_exist=True), responses)
f.write_new(inside(out), result)
return {'saved': out, 'kind': result['kind'], 'pending': len(result['judgments']),
'note': 'Every human judgment starts pending. Never fill one in for the reviewer.'}
def view(folder, out, report=''):
core()
from scripts.viewer import export_viewer
path = export_viewer(inside(folder, must_exist=True), inside(out), optional(report))
return {'saved': str(path), 'note': 'Read-only snapshot. Regenerate to a new filename after edits.'}
def review_page(report, out):
core()
from scripts.review_page import export_review_page
return {'saved': str(export_review_page(inside(report, must_exist=True), inside(out)))}
def validate_review(report, folder='', require_complete=False):
f = core()
from scripts.review_evidence import validate_review as run
counts = run(f.read(inside(report, must_exist=True)), optional(folder), require_complete)
return {'valid': True, 'counts': counts, 'note': 'A valid record is not content approval.'}
def sources_audit(folder, as_of, provenance='', max_age_days=0):
f = core()
from scripts.provenance import audit_sources
config = f.read(optional(provenance)) if provenance else None
return audit_sources(inside(folder, must_exist=True), as_of, config, max_age_days or None)
def export_consumer(folder, consumer, as_of, out, include_drafts=False, provenance=''):
f = core()
from scripts.consumer import export_consumer as run
config = f.read(optional(provenance)) if provenance else None
result = run(inside(folder, must_exist=True), consumer, as_of, include_drafts, config)
f.write_new(inside(out), result)
return {'saved': out, 'note': 'Draft review artifact only; no consumer installed, run or evaluated.'}
def text(description):
return {'type': 'string', 'description': description}
def flag(description):
return {'type': 'boolean', 'description': description}
FOLDER = text('Project folder inside the workspace, for example my-content-system')
NEW_FILE = text('New output path inside the workspace; existing files are never overwritten')
TOOLS = knowledge_tools() + [
Tool('fievel_setup_status', 'Check Fievel setup',
'Report whether Python and the jsonschema dependency are ready, and the workspace Fievel works in.',
{}, [], setup_status),
Tool('fievel_init', 'Create a project',
'Create a NEW Fievel project folder from the empty template, or from the fictional Wayfarer example. '
'Refuses an existing folder.', {'folder': FOLDER, 'example': flag('Use the fictional practice example')},
['folder'], init, read_only=False),
Tool('fievel_validate', 'Validate a project',
'Check a project\'s structure and links and list rules that still lack quality checks. '
'Not content approval.', {'folder': FOLDER, 'require_coverage': flag('Also fail if any rule lacks a check')},
['folder'], validate),
Tool('fievel_status', 'Show project status',
'Return saved stages, decisions, open questions, next action and uncovered rules for a project.',
{'folder': FOLDER}, ['folder'], status),
Tool('fievel_check', 'Check saved responses',
'Run literal checks against saved responses (JSON mapping situation IDs to response text) and save a new '
'report. Human questions stay not_run.', {'folder': FOLDER, 'responses_file': text('Saved responses JSON file'),
'out': NEW_FILE}, ['folder', 'responses_file', 'out'], check, read_only=False),
Tool('fievel_review', 'Prepare human review',
'Save a new human review sheet for saved responses, with every judgment pending. With evidence_config, '
'prepare a self-contained evidence review.', {'folder': FOLDER, 'responses_file': text('Saved responses JSON file'),
'out': NEW_FILE, 'evidence_config': text('Optional review evidence configuration file')},
['folder', 'responses_file', 'out'], review, read_only=False),
Tool('fievel_view', 'Generate overview',
'Generate a read-only offline HTML overview of a project, optionally with a selected check report.',
{'folder': FOLDER, 'out': text('New .html file inside the workspace'),
'report': text('Optional check report to reproduce')}, ['folder', 'out'], view, read_only=False),
Tool('fievel_review_page', 'Generate review form',
'Generate an offline HTML form for completing a saved evidence review.',
{'report': text('Evidence review JSON file'), 'out': text('New .html file inside the workspace')},
['report', 'out'], review_page, read_only=False),
Tool('fievel_validate_review', 'Validate a review record',
'Validate a saved evidence review, optionally rejecting it when stale for a project or incomplete.',
{'report': text('Evidence review JSON file'), 'folder': text('Optional project folder to compare'),
'require_complete': flag('Fail unless every judgment is complete')}, ['report'], validate_review),
Tool('fievel_sources_audit', 'Audit source provenance',
'Audit declared source metadata and optional evidence links. Never opens source locations.',
{'folder': FOLDER, 'as_of': text('Audit date, YYYY-MM-DD'), 'provenance': text('Optional provenance file'),
'max_age_days': {'type': 'integer', 'description': 'Optional freshness window the owner supplied'}},
['folder', 'as_of'], sources_audit),
Tool('fievel_export_consumer', 'Export for a consumer',
'Save a draft review export of selected rules and provenance for a named consuming AI. Installs nothing.',
{'folder': FOLDER, 'consumer': text('Name of the consuming AI or team'), 'as_of': text('Date, YYYY-MM-DD'),
'out': NEW_FILE, 'include_drafts': flag('Include draft rules for design review'),
'provenance': text('Optional provenance file')}, ['folder', 'consumer', 'as_of', 'out'],
export_consumer, read_only=False),
]
SERVER = Server('fievel-local', VERSION,
'Fievel tools for a content system in this workspace. Follow the Fievel skills: nothing here '
'approves content, and human judgments are never filled in by tools.', TOOLS)
def main():
sys.stdout.reconfigure(encoding='utf-8', newline='\n')
for line in sys.stdin:
if not line.strip():
continue
try:
message = json.loads(line)
except ValueError:
response = {'jsonrpc': '2.0', 'id': None, 'error': {'code': -32700, 'message': 'Parse error'}}
else:
response = SERVER.handle(message)
if response is not None:
sys.stdout.write(json.dumps(response, ensure_ascii=False) + '\n')
sys.stdout.flush()
if __name__ == '__main__':
main()
SHA-256: 5d37df617503bee8e9d585b62581a25e12c985208f7fac205c51281596104ade