Skip to content
Open
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
1 change: 1 addition & 0 deletions dribdat/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ def init_talisman(app):
def register_blueprints(app):
"""Register Flask blueprints."""
app.register_blueprint(public.api.blueprint)
app.register_blueprint(public.mcp.blueprint)
app.register_blueprint(public.auth.blueprint)
app.register_blueprint(public.views.blueprint)
app.register_blueprint(public.feeds.blueprint)
Expand Down
2 changes: 1 addition & 1 deletion dribdat/public/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""The public module, including the homepage, API and user auth."""
from . import views, project, auth, api, feeds # noqa
from . import views, project, auth, api, feeds, mcp # noqa
231 changes: 231 additions & 0 deletions dribdat/public/mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import uuid
import json
import asyncio
import logging
import time
from flask import (
Blueprint,
render_template,
redirect,
url_for,
request,
flash,
jsonify,
current_app,
Response,
stream_with_context
)
from flask_login import login_required, current_user
from mcp.server.fastmcp import FastMCP
from mcp.shared.exceptions import McpError
import mcp.types as types
from ..user.models import Event, Project, Activity, User
from ..database import db
from ..utils import markdownit
from ..public.projhelper import project_action
from ..user.constants import PR_CHALLENGE
from ..user import stageProjectToNext

blueprint = Blueprint("mcp", __name__)

# Initialize FastMCP server
mcp_server = FastMCP("Dribdat")

# --- MCP Tools ---

@mcp_server.tool()
def get_event_info(event_id: int = None):
"""Get information about a specific event or the current one."""
if event_id:
event = Event.query.get(event_id)
else:
event = Event.query.filter_by(is_current=True).first() or \
Event.query.order_by(Event.id.desc()).first()

if not event:
return "No event found."

return json.dumps(event.get_full_data(), default=str)

@mcp_server.tool()
def search_projects(query: str, event_id: int = None):
"""Search for projects matching a query string."""
q = Project.query.filter(Project.is_hidden == False)
if event_id:
q = q.filter_by(event_id=event_id)

search_query = f"%{query}%"
projects = q.filter(
(Project.name.ilike(search_query)) |
(Project.summary.ilike(search_query)) |
(Project.longtext.ilike(search_query))
).all()

return json.dumps([p.data for p in projects], default=str)

@mcp_server.tool()
def get_project_details(project_id: int):
"""Get full details of a project by ID."""
project = Project.query.get(project_id)
if not project:
return "Project not found."

data = project.data
data['longtext'] = project.longtext
data['autotext'] = project.autotext
return json.dumps(data, default=str)

@mcp_server.tool()
def get_activities(project_id: int = None, limit: int = 10):
"""Get recent activities/posts, optionally filtered by project."""
q = Activity.query
if project_id:
q = q.filter_by(project_id=project_id)

activities = q.order_by(Activity.timestamp.desc()).limit(limit).all()
return json.dumps([a.data for a in activities], default=str)

@mcp_server.tool()
def add_post(project_id: int, text: str):
"""Add a new post/update to a project."""
# Note: Authentication context is handled via the transport/token
user = getattr(request, 'mcp_user', None)
if not user:
return "Error: MCP session not authenticated."

project = Project.query.get(project_id)
if not project:
return "Error: Project not found."

project_action(project.id, "update", action="post", text=text, for_user=user)
return f"Post added to project '{project.name}'."

@mcp_server.tool()
def update_project_status(project_id: int):
"""Promote a project to the next stage/level."""
user = getattr(request, 'mcp_user', None)
if not user:
return "Error: MCP session not authenticated."

project = Project.query.get(project_id)
if not project:
return "Error: Project not found."

# Check if user is allowed to edit
if not user.is_admin and project.user_id != user.id:
# Check if user has starred (joined) the project
starred = Activity.query.filter_by(
name='star', project_id=project.id, user_id=user.id
).first()
if not starred:
return "Error: You do not have permission to update this project."

if stageProjectToNext(project):
project.update_now()
db.session.add(project)
db.session.commit()
return f"Project '{project.name}' promoted to stage '{project.phase}'."
else:
return f"Project '{project.name}' is already at the maximum stage or not ready for promotion."

# --- MCP Blueprint Routes ---

@blueprint.route("/mcp/auth")
@login_required
def mcp_auth():
"""Page for user to get their one-time MCP token."""
if not current_user.mcp_token:
current_user.mcp_token = str(uuid.uuid4())
db.session.add(current_user)
db.session.commit()
return render_template("public/mcp_auth.html", token=current_user.mcp_token)

@blueprint.route("/api/mcp/sse")
def mcp_sse():
"""SSE endpoint for MCP."""
token = request.args.get("token")
user = User.query.filter_by(mcp_token=token).first()
if not user:
return "Unauthorized", 401

def event_stream():
# First message is the endpoint for client-to-server messages
msg_url = url_for(".mcp_messages", token=token, _external=True)
yield f"event: endpoint\ndata: {msg_url}\n\n"

while True:
time.sleep(30)
yield ": keep-alive\n\n"

return Response(stream_with_context(event_stream()), mimetype="text/event-stream")

@blueprint.route("/api/mcp/messages", methods=["POST"])
def mcp_messages():
"""Endpoint for client-to-server messages."""
token = request.args.get("token")
user = User.query.filter_by(mcp_token=token).first()
if not user:
return "Unauthorized", 401

# Attach user to request for tools to use
request.mcp_user = user

payload = request.get_json()
method = payload.get('method')
params = payload.get('params', {})
request_id = payload.get('id')

try:
if method == "initialize":
result = {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "Dribdat",
"version": "0.1.0"
}
}
return jsonify({"jsonrpc": "2.0", "id": request_id, "result": result})

elif method == "tools/list":
tools_list = []
for name, tool in mcp_server._tool_manager.list_tools():
tools_list.append({
"name": name,
"description": tool.description,
"inputSchema": tool.parameters.model_json_schema()
})
return jsonify({"jsonrpc": "2.0", "id": request_id, "result": {"tools": tools_list}})

elif method == "tools/call":
tool_name = params.get('name')
tool_args = params.get('arguments', {})

# Lookup the tool and call it
tool = next((t for n, t in mcp_server._tool_manager.list_tools() if n == tool_name), None)
if not tool:
return jsonify({"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": "Tool not found"}})

# Execute tool (FastMCP tools can be sync or async)
import inspect
if inspect.iscoroutinefunction(tool.fn):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
res_content = loop.run_until_complete(tool.fn(**tool_args))
else:
res_content = tool.fn(**tool_args)

return jsonify({
"jsonrpc": "2.0",
"id": request_id,
"result": {
"content": [{"type": "text", "text": str(res_content)}]
}
})

return jsonify({"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": "Method not found"}})
except Exception as e:
current_app.logger.error(f"MCP Error: {str(e)}")
return jsonify({"jsonrpc": "2.0", "id": request_id, "error": {"code": -32603, "message": str(e)}})
54 changes: 54 additions & 0 deletions dribdat/templates/public/mcp_auth.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{% extends "layout.html" %}

{% block page_title %}MCP Authentication{% endblock %}

{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8 text-center">
<h1 class="huge">MCP Authentication</h1>
<p class="lead mt-4">
Use the following token to connect your Model Context Protocol (MCP) client to Dribdat.
</p>

<div class="card my-5 bg-light">
<div class="card-body">
<h3 class="mb-3 text-muted">Your Connection Token</h3>
<code class="h2 p-3 d-block border rounded bg-white text-primary" id="mcpToken">{{ token }}</code>
<button class="btn btn-outline-secondary mt-3" onclick="copyToken()">
<i class="fa fa-copy"></i> Copy to Clipboard
</button>
</div>
</div>

<div class="text-left bg-white p-4 border rounded">
<h4>Connection Instructions</h4>
<p>To use this server with an MCP client (like MCP Inspector or a custom agent), use the following SSE URL:</p>
<div class="bg-light p-2 border rounded">
<code>{{ url_for('mcp.mcp_sse', token=token, _external=True) }}</code>
</div>
<p class="mt-3 text-muted">
<small>
<i class="fa fa-info-circle"></i> This token provides access to your account via MCP. Keep it secure and do not share it.
</small>
</p>
</div>

<div class="mt-5">
<a href="{{ url_for('public.user_profile', username=current_user.username) }}" class="btn btn-warning">
<i class="fa fa-arrow-left"></i> Back to Profile
</a>
</div>
</div>
</div>
</div>

<script>
function copyToken() {
const tokenText = document.getElementById('mcpToken').innerText;
navigator.clipboard.writeText(tokenText).then(() => {
alert('Token copied to clipboard!');
});
}
</script>
{% endblock %}
12 changes: 12 additions & 0 deletions dribdat/templates/public/useredit.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ <h2 class="huge col-5">

{{ render_form(url_for('auth.user_profile'), form, formid='userEdit') }}

<div class="row mt-4">
<div class="col text-center">
<a href="{{ url_for('mcp.mcp_auth') }}" class="btn btn-dark btn-lg">
<i class="fa fa-terminal"></i>
Connect to MCP
</a>
<p class="mt-2 text-muted">
Connect your AI agent or MCP client to Dribdat.
</p>
</div>
</div>

<hr>

{% if user.sso_id %}
Expand Down
1 change: 1 addition & 0 deletions dribdat/user/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class User(UserMixin, PkModel):
sso_id = Column(db.String(128), nullable=True)
# A temporary hash for logins
hashword = Column(db.String(128), nullable=True)
mcp_token = Column(db.String(128), nullable=True)
updated_at = Column(db.DateTime(timezone=True), nullable=True, default=func.now())
# The hashed password
password = Column(db.String(128), nullable=True)
Expand Down
32 changes: 32 additions & 0 deletions migrations/versions/b0bfebbaa9db_add_mcp_token_to_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Add mcp_token to User

Revision ID: b0bfebbaa9db
Revises: ac23802e6154
Create Date: 2026-04-04 20:28:47.349111

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'b0bfebbaa9db'
down_revision = 'ac23802e6154'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(sa.Column('mcp_token', sa.String(length=128), nullable=True))

# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('mcp_token')

# ### end Alembic commands ###
Loading